diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/tests/views | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/tests/views')
19 files changed, 47206 insertions, 0 deletions
diff --git a/addons/web/static/tests/views/abstract_controller_tests.js b/addons/web/static/tests/views/abstract_controller_tests.js new file mode 100644 index 00000000..43b71b52 --- /dev/null +++ b/addons/web/static/tests/views/abstract_controller_tests.js @@ -0,0 +1,168 @@ +odoo.define("base.abstract_controller_tests", function (require) { +"use strict"; + +const { xml } = owl.tags; + +var testUtils = require("web.test_utils"); +var createView = testUtils.createView; +var BasicView = require("web.BasicView"); +var BasicRenderer = require("web.BasicRenderer"); +const AbstractRenderer = require('web.AbstractRendererOwl'); +const RendererWrapper = require('web.RendererWrapper'); + +function getHtmlRenderer(html) { + return BasicRenderer.extend({ + start: function () { + this.$el.html(html); + return this._super.apply(this, arguments); + } + }); +} + +function getOwlView(owlRenderer, viewType) { + viewType = viewType || "test"; + return BasicView.extend({ + viewType: viewType, + config: _.extend({}, BasicView.prototype.config, { + Renderer: owlRenderer, + }), + getRenderer() { + return new RendererWrapper(null, this.config.Renderer, {}); + } + }); +} + +function getHtmlView(html, viewType) { + viewType = viewType || "test"; + return BasicView.extend({ + viewType: viewType, + config: _.extend({}, BasicView.prototype.config, { + Renderer: getHtmlRenderer(html) + }) + }); +} + +QUnit.module("Views", { + beforeEach: function () { + this.data = { + test_model: { + fields: {}, + records: [] + } + }; + } +}, function () { + QUnit.module('AbstractController'); + + QUnit.test('click on a a[type="action"] child triggers the correct action', async function (assert) { + assert.expect(7); + + var html = + "<div>" + + '<a name="a1" type="action" class="simple">simple</a>' + + '<a name="a2" type="action" class="with-child">' + + "<span>child</input>" + + "</a>" + + '<a type="action" data-model="foo" data-method="bar" class="method">method</a>' + + '<a type="action" data-model="foo" data-res-id="42" class="descr">descr</a>' + + '<a type="action" data-model="foo" class="descr2">descr2</a>' + + "</div>"; + + var view = await createView({ + View: getHtmlView(html, "test"), + data: this.data, + model: "test_model", + arch: "<test/>", + intercepts: { + do_action: function (event) { + assert.step(event.data.action.name || event.data.action); + } + }, + mockRPC: function (route, args) { + if (args.model === 'foo' && args.method === 'bar') { + assert.step("method"); + return Promise.resolve({name: 'method'}); + } + return this._super.apply(this, arguments); + } + }); + await testUtils.dom.click(view.$(".simple")); + await testUtils.dom.click(view.$(".with-child span")); + await testUtils.dom.click(view.$(".method")); + await testUtils.dom.click(view.$(".descr")); + await testUtils.dom.click(view.$(".descr2")); + assert.verifySteps(["a1", "a2", "method", "method", "descr", "descr2"]); + + view.destroy(); + }); + + QUnit.test('OWL Renderer correctly destroyed', async function (assert) { + assert.expect(2); + + class Renderer extends AbstractRenderer { + __destroy() { + assert.step("destroy"); + super.__destroy(); + } + } + Renderer.template = xml`<div>Test</div>`; + + var view = await createView({ + View: getOwlView(Renderer, "test"), + data: this.data, + model: "test_model", + arch: "<test/>", + }); + view.destroy(); + + assert.verifySteps(["destroy"]); + + }); + + QUnit.test('Correctly set focus to search panel with Owl Renderer', async function (assert) { + assert.expect(1); + + class Renderer extends AbstractRenderer { } + Renderer.template = xml`<div>Test</div>`; + + var view = await createView({ + View: getOwlView(Renderer, "test"), + data: this.data, + model: "test_model", + arch: "<test/>", + }); + assert.hasClass(document.activeElement, "o_searchview_input"); + view.destroy(); + }); + + QUnit.test('Owl Renderer mounted/willUnmount hooks are properly called', async function (assert) { + // This test could be removed as soon as controllers and renderers will + // both be converted in Owl. + assert.expect(3); + + class Renderer extends AbstractRenderer { + mounted() { + assert.step("mounted"); + } + willUnmount() { + assert.step("unmounted"); + } + } + Renderer.template = xml`<div>Test</div>`; + + const view = await createView({ + View: getOwlView(Renderer, "test"), + data: this.data, + model: "test_model", + arch: "<test/>", + }); + + view.destroy(); + + assert.verifySteps([ + "mounted", + "unmounted", + ]); + }); +}); +}); diff --git a/addons/web/static/tests/views/abstract_model_tests.js b/addons/web/static/tests/views/abstract_model_tests.js new file mode 100644 index 00000000..544a908b --- /dev/null +++ b/addons/web/static/tests/views/abstract_model_tests.js @@ -0,0 +1,130 @@ +odoo.define('web.abstract_model_tests', function (require) { + "use strict"; + + const AbstractModel = require('web.AbstractModel'); + const Domain = require('web.Domain'); + + QUnit.module('Views', {}, function () { + QUnit.module('AbstractModel'); + + QUnit.test('leave sample mode when unknown route is called on sample server', async function (assert) { + assert.expect(4); + + const Model = AbstractModel.extend({ + _isEmpty() { + return true; + }, + async __load() { + if (this.isSampleModel) { + await this._rpc({ model: 'partner', method: 'unknown' }); + } + }, + }); + + const model = new Model(null, { + modelName: 'partner', + fields: {}, + useSampleModel: true, + SampleModel: Model, + }); + + assert.ok(model.useSampleModel); + assert.notOk(model._isInSampleMode); + + await model.load({}); + + assert.notOk(model.useSampleModel); + assert.notOk(model._isInSampleMode); + + model.destroy(); + }); + + QUnit.test("don't cath general error on sample server in sample mode", async function (assert) { + assert.expect(5); + + const error = new Error(); + + const Model = AbstractModel.extend({ + _isEmpty() { + return true; + }, + async __reload() { + if (this.isSampleModel) { + await this._rpc({ model: 'partner', method: 'read_group' }); + } + }, + async _rpc() { + throw error; + }, + }); + + const model = new Model(null, { + modelName: 'partner', + fields: {}, + useSampleModel: true, + SampleModel: Model, + }); + + assert.ok(model.useSampleModel); + assert.notOk(model._isInSampleMode); + + await model.load({}); + + assert.ok(model.useSampleModel); + assert.ok(model._isInSampleMode); + + async function reloadModel() { + try { + await model.reload(); + } catch (e) { + assert.strictEqual(e, error); + } + } + + await reloadModel(); + + model.destroy(); + }); + + QUnit.test('fetch sample data: concurrency', async function (assert) { + assert.expect(3); + + const Model = AbstractModel.extend({ + _isEmpty() { + return true; + }, + __get() { + return { isSample: !!this.isSampleModel }; + }, + }); + + const model = new Model(null, { + modelName: 'partner', + fields: {}, + useSampleModel: true, + SampleModel: Model, + }); + + await model.load({ domain: Domain.FALSE_DOMAIN, }); + + const beforeReload = model.get(null, { withSampleData: true }); + + const reloaded = model.reload(null, { domain: Domain.TRUE_DOMAIN }); + const duringReload = model.get(null, { withSampleData: true }); + + await reloaded; + + const afterReload = model.get(null, { withSampleData: true }); + + assert.strictEqual(beforeReload.isSample, true, + "Sample data flag must be true before reload" + ); + assert.strictEqual(duringReload.isSample, true, + "Sample data flag must be true during reload" + ); + assert.strictEqual(afterReload.isSample, false, + "Sample data flag must be true after reload" + ); + }); + }); +}); diff --git a/addons/web/static/tests/views/abstract_view_banner_tests.js b/addons/web/static/tests/views/abstract_view_banner_tests.js new file mode 100644 index 00000000..e5aa14fa --- /dev/null +++ b/addons/web/static/tests/views/abstract_view_banner_tests.js @@ -0,0 +1,108 @@ +odoo.define('web.abstract_view_banner_tests', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var AbstractView = require('web.AbstractView'); + +var testUtils = require('web.test_utils'); +var createView = testUtils.createView; + +var TestRenderer = AbstractRenderer.extend({ + _renderView: function () { + this.$el.addClass('test_content'); + return this._super(); + }, +}); + +var TestView = AbstractView.extend({ + type: 'test', + config: _.extend({}, AbstractView.prototype.config, { + Renderer: TestRenderer + }), +}); + +var test_css_url = '/test_assetsbundle/static/src/css/test_cssfile1.css'; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + test_model: { + fields: {}, + records: [], + }, + }; + }, + afterEach: function () { + $('head link[href$="' + test_css_url + '"]').remove(); + } + }, function () { + QUnit.module('BasicRenderer'); + + QUnit.test("The banner should be fetched from the route", function (assert) { + var done = assert.async(); + assert.expect(6); + + var banner_html =` + <div class="modal o_onboarding_modal o_technical_modal" tabindex="-1" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-footer"> + <a type="action" class="btn btn-primary" data-dismiss="modal" + data-toggle="collapse" href=".o_onboarding_container"> + Remove + </a> + </div> + </div> + </div> + </div> + <div class="o_onboarding_container collapse show"> + <div class="o_onboarding_wrap"> + <a href="#" data-toggle="modal" data-target=".o_onboarding_modal" + class="float-right o_onboarding_btn_close"> + <i class="fa fa-times" title="Close the onboarding panel" /> + </a> + </div> + <div> + <link type="text/css" href="` + test_css_url + `" rel="stylesheet"> + <div class="hello_banner">Here is the banner</div> + </div> + </div>`; + + createView({ + View: TestView, + model: 'test_model', + data: this.data, + arch: '<test banner_route="/module/hello_banner"/>', + mockRPC: function (route, args) { + if (route === '/module/hello_banner') { + assert.step(route); + return Promise.resolve({html: banner_html}); + } + return this._super(route, args); + }, + }).then(async function (view) { + var $banner = view.$('.hello_banner'); + assert.strictEqual($banner.length, 1, + "The view should contain the response from the controller."); + assert.verifySteps(['/module/hello_banner'], "The banner should be fetched."); + + var $head_link = $('head link[href$="' + test_css_url + '"]'); + assert.strictEqual($head_link.length, 1, + "The stylesheet should have been added to head."); + + var $banner_link = $('link[href$="' + test_css_url + '"]', $banner); + assert.strictEqual($banner_link.length, 0, + "The stylesheet should have been removed from the banner."); + + await testUtils.dom.click(view.$('.o_onboarding_btn_close')); // click on close to remove banner + await testUtils.dom.click(view.$('.o_technical_modal .btn-primary:contains("Remove")')); // click on button remove from techinal modal + assert.strictEqual(view.$('.o_onboarding_container.show').length, 0, + "Banner should be removed from the view"); + + view.destroy(); + done(); + }); + }); + } +); +}); diff --git a/addons/web/static/tests/views/abstract_view_tests.js b/addons/web/static/tests/views/abstract_view_tests.js new file mode 100644 index 00000000..b2e0cee0 --- /dev/null +++ b/addons/web/static/tests/views/abstract_view_tests.js @@ -0,0 +1,146 @@ +odoo.define('web.abstract_view_tests', function (require) { +"use strict"; + +var AbstractView = require('web.AbstractView'); +var ajax = require('web.ajax'); +var testUtils = require('web.test_utils'); + +var createActionManager = testUtils.createActionManager; +var createView = testUtils.createView; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + fake_model: { + fields: {}, + record: [], + }, + foo: { + fields: { + foo: {string: "Foo", type: "char"}, + bar: {string: "Bar", type: "boolean"}, + }, + records: [ + {id: 1, bar: true, foo: "yop"}, + {id: 2, bar: true, foo: "blip"}, + ] + }, + }; + }, +}, function () { + + QUnit.module('AbstractView'); + + QUnit.test('lazy loading of js libs (in parallel)', async function (assert) { + var done = assert.async(); + assert.expect(6); + + var prom = testUtils.makeTestPromise(); + var loadJS = ajax.loadJS; + ajax.loadJS = function (url) { + assert.step(url); + return prom.then(function () { + assert.step(url + ' loaded'); + }); + }; + + var View = AbstractView.extend({ + jsLibs: [['a', 'b']], + }); + createView({ + View: View, + arch: '<fake/>', + data: this.data, + model: 'fake_model', + }).then(function (view) { + assert.verifySteps(['a loaded', 'b loaded'], + "should wait for both libs to be loaded"); + ajax.loadJS = loadJS; + view.destroy(); + done(); + }); + + await testUtils.nextTick(); + assert.verifySteps(['a', 'b'], "both libs should be loaded in parallel"); + prom.resolve(); + }); + + QUnit.test('lazy loading of js libs (sequentially)', async function (assert) { + var done = assert.async(); + assert.expect(10); + + var proms = { + a: testUtils.makeTestPromise(), + b: testUtils.makeTestPromise(), + c: testUtils.makeTestPromise(), + }; + var loadJS = ajax.loadJS; + ajax.loadJS = function (url) { + assert.step(url); + return proms[url].then(function () { + assert.step(url + ' loaded'); + }); + }; + + var View = AbstractView.extend({ + jsLibs: [ + ['a', 'b'], + 'c', + ], + }); + createView({ + View: View, + arch: '<fake/>', + data: this.data, + model: 'fake_model', + }).then(function (view) { + assert.verifySteps(['c loaded'], "should wait for all libs to be loaded"); + ajax.loadJS = loadJS; + view.destroy(); + done(); + }); + await testUtils.nextTick(); + assert.verifySteps(['a', 'b'], "libs 'a' and 'b' should be loaded in parallel"); + await proms.b.resolve(); + await testUtils.nextTick(); + assert.verifySteps(['b loaded'], "should wait for 'a' and 'b' to be loaded before loading 'c'"); + await proms.a.resolve(); + await testUtils.nextTick(); + assert.verifySteps(['a loaded', 'c'], "should load 'c' when 'a' and 'b' are loaded"); + await proms.c.resolve(); + }); + + QUnit.test('group_by from context can be a string, instead of a list of strings', async function (assert) { + assert.expect(1); + + var actionManager = await createActionManager({ + actions: [{ + id: 1, + name: 'Foo', + res_model: 'foo', + type: 'ir.actions.act_window', + views: [[false, 'list']], + context: { + group_by: 'bar', + }, + }], + archs: { + 'foo,false,list': '<tree><field name="foo"/><field name="bar"/></tree>', + 'foo,false,search': '<search></search>', + }, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + assert.deepEqual(args.kwargs.groupby, ['bar']); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction(1); + + actionManager.destroy(); + }); + +}); +}); diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js new file mode 100644 index 00000000..a97fb0dd --- /dev/null +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -0,0 +1,2533 @@ +odoo.define('web.basic_model_tests', function (require) { + "use strict"; + + var BasicModel = require('web.BasicModel'); + var FormView = require('web.FormView'); + var testUtils = require('web.test_utils'); + + var createModel = testUtils.createModel; + var createView = testUtils.createView; + + QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + display_name: { string: "STRING", type: 'char' }, + // the following 2 fields must remain in that order to check that + // active has priority over x_active despite the order + x_active: { string: "Custom Active", type: 'boolean', default: true}, + active: {string: "Active", type: 'boolean', default: true}, + total: { string: "Total", type: 'integer' }, + foo: { string: "Foo", type: 'char' }, + bar: { string: "Bar", type: 'integer' }, + qux: { string: "Qux", type: 'many2one', relation: 'partner' }, + product_id: { string: "Favorite product", type: 'many2one', relation: 'product' }, + product_ids: { string: "Favorite products", type: 'one2many', relation: 'product' }, + category: { string: "Category M2M", type: 'many2many', relation: 'partner_type' }, + date: { string: "Date Field", type: 'date' }, + reference: { string: "Reference Field", type: 'reference', selection: [["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]] }, + }, + records: [ + { id: 1, foo: 'blip', bar: 1, product_id: 37, category: [12], display_name: "first partner", date: "2017-01-25" }, + { id: 2, foo: 'gnap', bar: 2, product_id: 41, display_name: "second partner" }, + ], + onchanges: {}, + }, + product: { + fields: { + display_name: { string: "Product Display Name", type: "char" }, + name: { string: "Product Name", type: "char" }, + category: { string: "Category M2M", type: 'many2many', relation: 'partner_type' }, + active: {string: "Active", type: 'boolean', default: true}, + }, + records: [ + { id: 37, display_name: "xphone" }, + { id: 41, display_name: "xpad" } + ] + }, + partner_type: { + fields: { + display_name: { string: "Partner Type", type: "char" }, + date: { string: "Date Field", type: 'date' }, + x_active: { string: "Custom Active", type: 'boolean', default: true}, + }, + records: [ + { id: 12, display_name: "gold", date: "2017-01-25" }, + { id: 14, display_name: "silver" }, + { id: 15, display_name: "bronze" } + ] + }, + partner_title: { + fields: { + display_name: { string: "Partner Title", type: 'char'}, + }, + records: [ + { id: 42, display_name: "Dr."}, + ] + } + }; + + // add related fields to category. + this.data.partner.fields.category.relatedFields = + $.extend(true, {}, this.data.partner_type.fields); + this.params = { + res_id: 2, + modelName: 'partner', + fields: this.data.partner.fields, + }; + }, + }, function () { + QUnit.module('BasicModel'); + + QUnit.test('can process x2many commands', async function (assert) { + assert.expect(6); + + this.data.partner.fields.product_ids.default = [[0, 0, { category: [] }]]; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <field name="product_ids"/> + </form> + `, + archs: { + 'product,false,list': ` + <tree> + <field name="display_name"/> + </tree> + `, + 'product,false,kanban': ` + <kanban> + <templates><t t-name="kanban-box"> + <div><field name="display_name"/></div> + </t></templates> + </kanban> + `, + }, + viewOptions: { + mode: 'edit', + }, + mockRPC(route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + + assert.verifySteps([ + 'load_views', + 'onchange', + ]); + assert.containsOnce(form, '.o_field_x2many_list', 'should have rendered a x2many list'); + assert.containsOnce(form, '.o_data_row', 'should have added 1 record as default'); + assert.containsOnce(form, '.o_field_x2many_list_row_add', 'should have rendered a x2many add row on list'); + form.destroy(); + }); + + QUnit.test('can process x2many commands (with multiple fields)', async function (assert) { + assert.expect(1); + + this.data.partner.fields.product_ids.default = [[0, 0, { category: [] }]]; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <field name="product_ids"/> + </form> + `, + archs: { + 'product,false,list': ` + <tree> + <field name="display_name"/> + <field name="active"/> + </tree> + `, + }, + mockRPC(route, args) { + if (args.method === "create") { + const product_ids = args.args[0].product_ids; + const values = product_ids[0][2]; + assert.strictEqual(values.active, true, "active field should be set"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickSave(form); + form.destroy(); + }); + + QUnit.test('can load a record', async function (assert) { + assert.expect(7); + + this.params.fieldNames = ['foo']; + this.params.context = { active_field: 2 }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.deepEqual(args.kwargs.context, { + active_field: 2, + bin_size: true, + someKey: 'some value', + }, "should have sent the correct context"); + return this._super.apply(this, arguments); + }, + session: { + user_context: { someKey: 'some value' }, + } + }); + + assert.strictEqual(model.get(1), null, "should return null for non existing key"); + + var resultID = await model.load(this.params); + // it is a string, because it is used as a key in an object + assert.strictEqual(typeof resultID, 'string', "result should be a valid id"); + + var record = model.get(resultID); + assert.strictEqual(record.res_id, 2, "res_id read should be the same as asked"); + assert.strictEqual(record.type, 'record', "should be of type 'record'"); + assert.strictEqual(record.data.foo, "gnap", "should correctly read value"); + assert.strictEqual(record.data.bar, undefined, "should not fetch the field 'bar'"); + model.destroy(); + }); + + QUnit.test('rejects loading a record with invalid id', async function (assert) { + assert.expect(1); + + this.params.res_id = 99; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + try { + await model.load(this.params); + } + catch (e) { + assert.ok("load should return a rejected deferred for an invalid id"); + } + + model.destroy(); + }); + + QUnit.test('notify change with many2one', async function (assert) { + assert.expect(2); + + this.params.fieldNames = ['foo', 'qux']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.qux, false, "qux field should be false"); + await model.notifyChanges(resultID, { qux: { id: 1, display_name: "hello" } }); + + record = model.get(resultID); + assert.strictEqual(record.data.qux.data.id, 1, "qux field should be 1"); + model.destroy(); + }); + + QUnit.test('notify change on many2one: unset and reset same value', async function (assert) { + assert.expect(3); + + this.data.partner.records[1].qux = 1; + + this.params.fieldNames = ['qux']; + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.qux.data.id, 1, "qux value should be 1"); + + await model.notifyChanges(resultID, { qux: false }); + record = model.get(resultID); + assert.strictEqual(record.data.qux, false, "qux should be unset"); + + await model.notifyChanges(resultID, { qux: { id: 1, display_name: 'second_partner' } }); + record = model.get(resultID); + assert.strictEqual(record.data.qux.data.id, 1, "qux value should be 1 again"); + model.destroy(); + }); + + QUnit.test('write on a many2one', async function (assert) { + assert.expect(4); + var self = this; + + this.params.fieldNames = ['product_id']; + + var rpcCount = 0; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xpad', + "should be initialized with correct value"); + + await model.notifyChanges(resultID, { product_id: { id: 37, display_name: 'xphone' } }); + + record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xphone', + "should be changed with correct value"); + + await model.save(resultID); + + assert.strictEqual(self.data.partner.records[1].product_id, 37, + "should have really saved the data"); + assert.strictEqual(rpcCount, 3, "should have done 3 rpc: 1 read, 1 write, 1 read"); + model.destroy(); + }); + + QUnit.test('basic onchange', async function (assert) { + assert.expect(5); + + this.data.partner.fields.foo.onChange = true; + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + }; + + this.params.fieldNames = ['foo', 'bar']; + this.params.context = { hello: 'world' }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + var context = args.kwargs.context; + assert.deepEqual(context, { hello: 'world' }, + "context should be sent by the onchange"); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.foo, 'gnap', "foo field is properly initialized"); + assert.strictEqual(record.data.bar, 2, "bar field is properly initialized"); + + await model.notifyChanges(resultID, { foo: 'mary poppins' }); + + record = model.get(resultID); + assert.strictEqual(record.data.foo, 'mary poppins', "onchange has been applied"); + assert.strictEqual(record.data.bar, 12, "onchange has been applied"); + model.destroy(); + }); + + QUnit.test('onchange with a many2one', async function (assert) { + assert.expect(5); + + this.data.partner.fields.product_id.onChange = true; + this.data.partner.onchanges.product_id = function (obj) { + if (obj.product_id === 37) { + obj.foo = "space lollipop"; + } + }; + + this.params.fieldNames = ['foo', 'product_id']; + + var rpcCount = 0; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.strictEqual(args.args[2], "product_id", + "should send the only changed field as a string, not a list"); + } + rpcCount++; + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.foo, 'gnap', "foo field is properly initialized"); + assert.strictEqual(record.data.product_id.data.id, 41, "product_id field is properly initialized"); + + await model.notifyChanges(resultID, { product_id: { id: 37, display_name: 'xphone' } }); + + record = model.get(resultID); + assert.strictEqual(record.data.foo, 'space lollipop', "onchange has been applied"); + assert.strictEqual(rpcCount, 2, "should have done 2 rpc: 1 read and 1 onchange"); + model.destroy(); + }); + + QUnit.test('onchange on a one2many not in view (fieldNames)', async function (assert) { + assert.expect(6); + + this.data.partner.fields.foo.onChange = true; + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + obj.product_ids = []; + }; + + this.params.fieldNames = ['foo']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.foo, 'gnap', "foo field is properly initialized"); + assert.strictEqual(record.data.bar, undefined, "bar field is not loaded"); + assert.strictEqual(record.data.product_ids, undefined, "product_ids field is not loaded"); + + await model.notifyChanges(resultID, { foo: 'mary poppins' }); + + record = model.get(resultID); + assert.strictEqual(record.data.foo, 'mary poppins', "onchange has been applied"); + assert.strictEqual(record.data.bar, 12, "onchange has been applied"); + assert.strictEqual(record.data.product_ids, undefined, + "onchange on product_ids (one2many) has not been applied"); + model.destroy(); + }); + + QUnit.test('notifyChange on a one2many', async function (assert) { + assert.expect(9); + + this.data.partner.records[1].product_ids = [37]; + this.params.fieldNames = ['product_ids']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'name_get') { + assert.strictEqual(args.model, 'product'); + } + return this._super(route, args); + }, + }); + + var o2mParams = { + modelName: 'product', + fields: this.data.product.fields, + fieldNames: ['display_name'], + }; + var resultID = await model.load(this.params); + var newRecordID = await model.load(o2mParams); + var record = model.get(resultID); + var x2mListID = record.data.product_ids.id; + + assert.strictEqual(record.data.product_ids.count, 1, + "there should be one record in the relation"); + + // trigger a 'ADD' command + await model.notifyChanges(resultID, { product_ids: { operation: 'ADD', id: newRecordID } }); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'ADD', id: newRecordID, + }], "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 2, + "there should be two records in the relation"); + + // trigger a 'UPDATE' command + await model.notifyChanges(resultID, { product_ids: { operation: 'UPDATE', id: newRecordID } }); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'ADD', id: newRecordID, + }, { + operation: 'UPDATE', id: newRecordID, + }], "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 2, + "there should be two records in the relation"); + + // trigger a 'DELETE' command on the existing record + var existingRecordID = record.data.product_ids.data[0].id; + await model.notifyChanges(resultID, { product_ids: { operation: 'DELETE', ids: [existingRecordID] } }); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'ADD', id: newRecordID, + }, { + operation: 'UPDATE', id: newRecordID, + }, { + operation: 'DELETE', id: existingRecordID, + }], + "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 1, + "there should be one record in the relation"); + + // trigger a 'DELETE' command on the new record + await model.notifyChanges(resultID, { product_ids: { operation: 'DELETE', ids: [newRecordID] } }); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'DELETE', id: existingRecordID, + }], "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 0, + "there should be no record in the relation"); + + model.destroy(); + }); + + QUnit.test('notifyChange on a many2one, without display_name', async function (assert) { + assert.expect(3); + + this.params.fieldNames = ['product_id']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'name_get') { + assert.strictEqual(args.model, 'product'); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xpad', + "product_id field is set to xpad"); + + await model.notifyChanges(resultID, { product_id: { id: 37 } }); + + record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xphone', + "display_name should have been fetched"); + model.destroy(); + }); + + QUnit.test('onchange on a char with an unchanged many2one', async function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.onChange = true; + this.data.partner.onchanges.foo = function (obj) { + obj.foo = obj.foo + " alligator"; + }; + + this.params.fieldNames = ['foo', 'product_id']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.strictEqual(args.args[1].product_id, 41, "should send correct value"); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + await model.notifyChanges(resultID, { foo: 'cookie' }); + var record = model.get(resultID); + assert.strictEqual(record.data.foo, 'cookie alligator', "onchange has been applied"); + model.destroy(); + }); + + QUnit.test('onchange on a char with another many2one not set to a value', async function (assert) { + assert.expect(2); + this.data.partner.records[0].product_id = false; + this.data.partner.fields.foo.onChange = true; + this.data.partner.onchanges.foo = function (obj) { + obj.foo = obj.foo + " alligator"; + }; + + this.params.fieldNames = ['foo', 'product_id']; + this.params.res_id = 1; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_id, false, "product_id is not set"); + + await model.notifyChanges(resultID, { foo: 'cookie' }); + record = model.get(resultID); + assert.strictEqual(record.data.foo, 'cookie alligator', "onchange has been applied"); + model.destroy(); + }); + + QUnit.test('can get a many2many', async function (assert) { + assert.expect(3); + + this.params.res_id = 1; + this.params.fieldsInfo = { + default: { + category: { + fieldsInfo: { default: { display_name: {} } }, + relatedFields: { display_name: { type: "char" } }, + viewType: 'default', + }, + }, + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.category.data[0].res_id, 12, + "should have loaded many2many res_ids"); + assert.strictEqual(record.data.category.data[0].data.display_name, "gold", + "should have loaded many2many display_name"); + record = model.get(resultID, { raw: true }); + assert.deepEqual(record.data.category, [12], + "with option raw, category should only return ids"); + model.destroy(); + }); + + QUnit.test('can use command add and get many2many value with date field', async function (assert) { + assert.expect(2); + + this.params.fieldsInfo = { + default: { + category: { + fieldsInfo: { default: { date: {} } }, + relatedFields: { date: { type: "date" } }, + viewType: 'default', + }, + }, + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var changes = { + category: { operation: 'ADD_M2M', ids: [{ id: 12 }] } + }; + await model.notifyChanges(resultID, changes); + var record = model.get(resultID); + assert.strictEqual(record.data.category.data.length, 1, "should have added one category"); + assert.strictEqual(record.data.category.data[0].data.date instanceof moment, + true, "should have a date parsed in a moment object"); + model.destroy(); + }); + + QUnit.test('many2many with ADD_M2M command and context with parent key', async function (assert) { + assert.expect(1); + + this.data.partner_type.fields.some_char = { type: "char" }; + this.params.fieldsInfo = { + default: { + category: { + fieldsInfo: { default: { some_char: { context: "{'a': parent.foo}" } } }, + relatedFields: { some_char: { type: "char" } }, + viewType: 'default', + }, + foo: {}, + }, + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var changes = { + category: { operation: 'ADD_M2M', ids: [{ id: 12 }] } + }; + await model.notifyChanges(resultID, changes); + var record = model.get(resultID); + var categoryRecord = record.data.category.data[0]; + assert.deepEqual(categoryRecord.getContext({ fieldName: 'some_char' }), { a: 'gnap' }, + "should properly evaluate context"); + model.destroy(); + }); + + QUnit.test('can fetch a list', async function (assert) { + assert.expect(4); + + this.params.fieldNames = ['foo']; + this.params.domain = []; + this.params.groupedBy = []; + this.params.res_id = undefined; + this.params.context = { active_field: 2 }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.strictEqual(args.context.active_field, 2, + "should have sent the correct context"); + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + + assert.strictEqual(record.type, 'list', "record fetched should be a list"); + assert.strictEqual(record.data.length, 2, "should have fetched 2 records"); + assert.strictEqual(record.data[0].data.foo, 'blip', "first record should have 'blip' in foo field"); + model.destroy(); + }); + + QUnit.test('fetch x2manys in list, with not too many rpcs', async function (assert) { + assert.expect(3); + + this.data.partner.records[0].category = [12, 15]; + this.data.partner.records[1].category = [12, 14]; + + this.params.fieldNames = ['category']; + this.params.domain = []; + this.params.groupedBy = []; + this.params.res_id = undefined; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(route); + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + + assert.strictEqual(record.data[0].data.category.data.length, 2, + "first record should have 2 categories loaded"); + assert.verifySteps(["/web/dataset/search_read"], + "should have done 2 rpc (searchread and read category)"); + model.destroy(); + }); + + QUnit.test('can make a default_record with the help of onchange', async function (assert) { + assert.expect(5); + + this.params.context = {}; + this.params.fieldNames = ['product_id', 'category', 'product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_id, false, "m2o default value should be false"); + assert.deepEqual(record.data.product_ids.data, [], "o2m default should be []"); + assert.deepEqual(record.data.category.data, [], "m2m default should be []"); + + assert.verifySteps(['onchange']); + + model.destroy(); + }); + + QUnit.test('default_get returning a non requested field', async function (assert) { + // 'default_get' returns a default value for the fields given in + // arguments. It should not return a value for fields that have not be + // requested. However, it happens (e.g. res.users), and the webclient + // should not crash when this situation occurs (the field should simply + // be ignored). + assert.expect(2); + + this.params.context = {}; + this.params.fieldNames = ['category']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + var result = this._super(route, args); + if (args.method === 'default_get') { + result.product_ids = [[6, 0, [37, 41]]]; + } + return result; + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.ok('category' in record.data, + "should have processed 'category'"); + assert.notOk('product_ids' in record.data, + "should have ignored 'product_ids'"); + + model.destroy(); + }); + + QUnit.test('can make a default_record with default relational values', async function (assert) { + assert.expect(6); + + this.data.partner.fields.product_id.default = 37; + this.data.partner.fields.product_ids.default = [ + [0, false, { name: 'xmac' }], + [0, false, { name: 'xcloud' }] + ]; + this.data.partner.fields.category.default = [ + [6, false, [12, 14]] + ]; + + this.params.fieldNames = ['product_id', 'category', 'product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + this.params.fieldsInfo = { + form: { + category: {}, + product_id: {}, + product_ids: { + fieldsInfo: { + default: { name: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + }, + }; + this.params.viewType = 'form'; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.deepEqual(record.data.product_id.data.display_name, 'xphone', + "m2o default should be xphone"); + assert.deepEqual(record.data.product_ids.data.length, + 2, "o2m default should have two records"); + assert.deepEqual(record.data.product_ids.data[0].data.name, + 'xmac', "first o2m default value should be xmac"); + assert.deepEqual(record.data.category.res_ids, [12, 14], + "m2m default should be [12, 14]"); + + assert.verifySteps(['onchange']); + + model.destroy(); + }); + + QUnit.test('default_record, with onchange on many2one', async function (assert) { + assert.expect(1); + + // the onchange is done by the mockRPC because we want to return a value + // of 'false', which does not work with the mockserver mockOnChange method. + this.data.partner.onchanges.product_id = true; + + this.params.context = {}; + this.params.fieldNames = ['product_id']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ value: { product_id: false } }); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_id, false, "m2o default value should be false"); + model.destroy(); + }); + + QUnit.test('default record: batch namegets on same model and res_id', async function (assert) { + assert.expect(3); + + var rpcCount = 0; + var fields = this.data.partner.fields; + fields.other_product_id = _.extend({}, fields.product_id); + fields.product_id.default = 37; + fields.other_product_id.default = 41; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + var params = { + context: {}, + fieldNames: ['other_product_id', 'product_id'], + fields: fields, + modelName: 'partner', + type: 'record', + }; + + var resultID = await model.load(params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, "xphone", + "should have fetched correct name"); + assert.strictEqual(record.data.other_product_id.data.display_name, "xpad", + "should have fetched correct name"); + assert.strictEqual(rpcCount, 1, "should have done 1 rpc: onchange"); + model.destroy(); + }); + + QUnit.test('undoing a change keeps the record dirty', async function (assert) { + assert.expect(4); + + this.params.fieldNames = ['foo']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.foo, "gnap", "foo field should properly be set"); + assert.ok(!model.isDirty(resultID), "record should not be dirty"); + await model.notifyChanges(resultID, { foo: "hello" }); + assert.ok(model.isDirty(resultID), "record should be dirty"); + await model.notifyChanges(resultID, { foo: "gnap" }); + assert.ok(model.isDirty(resultID), "record should be dirty"); + model.destroy(); + }); + + QUnit.test('isDirty works correctly on list made empty', async function (assert) { + assert.expect(3); + + this.params.fieldNames = ['category']; + this.params.res_id = 1; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + var category_value = record.data.category; + assert.ok(_.isObject(category_value), "category field should have been fetched"); + assert.strictEqual(category_value.data.length, 1, "category field should contain one record"); + await model.notifyChanges(resultID, { + category: { + operation: 'DELETE', + ids: [category_value.data[0].id], + } + }); + assert.ok(model.isDirty(resultID), "record should be considered dirty"); + model.destroy(); + }); + + QUnit.test('can duplicate a record', async function (assert) { + assert.expect(4); + + this.params.fieldNames = ['foo']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.display_name, "second partner", + "record should have correct display name"); + assert.strictEqual(record.data.foo, "gnap", "foo should be set to correct value"); + var duplicateID = await model.duplicateRecord(resultID); + var duplicate = model.get(duplicateID); + assert.strictEqual(duplicate.data.display_name, "second partner (copy)", + "record should have been duplicated"); + assert.strictEqual(duplicate.data.foo, "gnap", "foo should be set to correct value"); + model.destroy(); + }); + + QUnit.test('record with many2one set to some value, then set it to none', async function (assert) { + assert.expect(3); + + this.params.fieldNames = ['product_id']; + + var self = this; + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xpad', "product_id should be set"); + await model.notifyChanges(resultID, { product_id: false }); + + record = model.get(resultID); + assert.strictEqual(record.data.product_id, false, "product_id should not be set"); + + await model.save(resultID); + + assert.strictEqual(self.data.partner.records[1].product_id, false, + "should have saved the new product_id value"); + model.destroy(); + }); + + QUnit.test('internal state of groups remains when reloading', async function (assert) { + assert.expect(10); + + this.params.fieldNames = ['foo']; + this.params.domain = []; + this.params.limit = 80; + this.params.groupedBy = ['product_id']; + this.params.res_id = undefined; + + var filterEnabled = false; + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'web_read_group' && filterEnabled) { + // as this is not yet supported by the MockServer, simulates + // a read_group that returns empty groups + // this is the case for several models (e.g. project.task + // grouped by stage_id) + return this._super.apply(this, arguments).then(function (result) { + // artificially filter out records of first group + result.groups[0].product_id_count = 0; + return result; + }); + } + return this._super.apply(this, arguments); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.length, 2, "should have 2 groups"); + var groupID = record.data[0].id; + assert.strictEqual(model.localData[groupID].parentID, resultID, + "parentID should be correctly set on groups"); + + await model.toggleGroup(groupID); + + record = model.get(resultID); + assert.ok(record.data[0].isOpen, "first group should be open"); + assert.strictEqual(record.data[0].data.length, 1, + "first group should have one record"); + assert.strictEqual(record.data[0].limit, 80, + "limit should be 80 by default"); + + // change the limit and offset of the first group + model.localData[record.data[0].id].limit = 10; + + await model.reload(resultID); + record = model.get(resultID); + assert.ok(record.data[0].isOpen, "first group should still be open"); + assert.strictEqual(record.data[0].data.length, 1, + "first group should still have one record"); + assert.strictEqual(record.data[0].limit, 10, + "new limit should have been kept"); + + // filter some records out: the open group should stay open but now + // be empty + filterEnabled = true; + await model.reload(resultID); + record = model.get(resultID); + assert.strictEqual(record.data[0].count, 0, + "first group's count should be 0"); + assert.strictEqual(record.data[0].data.length, 0, + "first group's data should be empty'"); + model.destroy(); + }); + + QUnit.test('group on date field with magic grouping method', async function (assert) { + assert.expect(1); + + this.params.fieldNames = ['foo']; + this.params.groupedBy = ['date:month']; + this.params.res_id = undefined; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + assert.deepEqual(args.kwargs.fields, ['foo', 'date'], + "should have correctly trimmed the magic grouping info from the field name"); + } + return this._super.apply(this, arguments); + }, + }); + + await model.load(this.params); + model.destroy(); + }); + + + QUnit.test('read group when grouped by a selection field', async function (assert) { + assert.expect(5); + + this.data.partner.fields.selection = { + type: 'selection', + selection: [['a', 'A'], ['b', 'B']], + }; + this.data.partner.records[0].selection = 'a'; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + var params = { + modelName: 'partner', + fields: this.data.partner.fields, + fieldNames: ['foo'], + groupedBy: ['selection'], + }; + + var resultID = await model.load(params); + var dataPoint = model.get(resultID); + assert.strictEqual(dataPoint.data.length, 2, "should have two groups"); + + var groupFalse = _.findWhere(dataPoint.data, { value: false }); + assert.ok(groupFalse, "should have a group for value false"); + assert.deepEqual(groupFalse.domain, [['selection', '=', false]], + "group's domain should be correct"); + + var groupA = _.findWhere(dataPoint.data, { value: 'A' }); + assert.ok(groupA, "should have a group for value 'a'"); + assert.deepEqual(groupA.domain, [['selection', '=', 'a']], + "group's domain should be correct"); + model.destroy(); + }); + + QUnit.test('create record, then save', async function (assert) { + assert.expect(5); + + this.params.fieldNames = ['product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + this.params.context = { active_field: 2 }; + + var id; + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'create') { + // has to be done before the call to _super + assert.deepEqual(args.args[0].product_ids, [], "should not have any command"); + assert.notOk('category' in args.args[0], "should not have other fields"); + + assert.strictEqual(args.kwargs.context.active_field, 2, + "record's context should be correctly passed"); + } + var result = this._super(route, args); + if (args.method === 'create') { + result.then(function (res) { + id = res; + }); + } + return result; + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + await model.save(record.id, { reload: false }); + record = model.get(resultID); + assert.strictEqual(record.res_id, id, "should have correct id from server"); + assert.strictEqual(record.data.id, id, "should have correct id from server"); + model.destroy(); + }); + + QUnit.test('write commands on a one2many', async function (assert) { + assert.expect(4); + + this.data.partner.records[1].product_ids = [37]; + + this.params.fieldNames = ['product_ids']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[0], [2], "should write on res_id = 2"); + var commands = args.args[1].product_ids; + assert.deepEqual(commands[0], [4, 37, false], "first command should be a 4"); + // TO DO: uncomment next line + // assert.strictEqual(commands[1], [0, false, {name: "toy"}], "second command should be a 0"); + assert.strictEqual(commands[1][0], 0, "second command should be a 0"); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID, { raw: true }); + assert.deepEqual(record.data.product_ids, [37], "should have correct initial value"); + + var relatedRecordID = await model.makeRecord('product', [{ + name: 'name', + string: "Product Name", + type: "char", + value: "xpod" + } + ]); + await model.notifyChanges(record.id, { + product_ids: { operation: "ADD", id: relatedRecordID } + }); + await model.save(record.id); + model.destroy(); + }); + + QUnit.test('create commands on a one2many', async function (assert) { + assert.expect(3); + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + return this._super(route, args); + }, + }); + + this.params.fieldsInfo = { + default: { + product_ids: { + fieldsInfo: { + default: { + display_name: { type: 'string' }, + } + }, + viewType: 'default', + } + } + }; + this.params.res_id = undefined; + this.params.type = 'record'; + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_ids.data.length, 0, + "one2many should start with a list of length 0"); + + await model.notifyChanges(record.id, { + product_ids: { + operation: "CREATE", + data: { + display_name: 'coucou', + }, + }, + }); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.data.length, 1, + "one2many should be a list of length 1"); + assert.strictEqual(record.data.product_ids.data[0].data.display_name, "coucou", + "one2many should have correct data"); + model.destroy(); + }); + + QUnit.test('onchange with a one2many on a new record', async function (assert) { + assert.expect(4); + + this.data.partner.fields.total.default = 50; + this.data.partner.fields.product_ids.onChange = true; + this.data.partner.onchanges.product_ids = function (obj) { + obj.total += 100; + }; + + this.params.fieldNames = ['total', 'product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + this.params.fieldsInfo = { + form: { + product_ids: { + fieldsInfo: { + default: { name: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + total: {}, + }, + }; + this.params.viewType = 'form'; + + var o2mRecordParams = { + fields: this.data.product.fields, + fieldNames: ['name'], + modelName: 'product', + type: 'record', + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'onchange' && args.args[1].total === 150) { + assert.deepEqual(args.args[1].product_ids, [[0, args.args[1].product_ids[0][1], { name: "xpod" }]], + "Should have sent the create command in the onchange"); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.product_ids.data.length, 0, + "one2many should start with a list of length 0"); + + // make a default record for the related model + var relatedRecordID = await model.load(o2mRecordParams); + // update the subrecord + await model.notifyChanges(relatedRecordID, { name: 'xpod' }); + // add the subrecord to the o2m of the main record + await model.notifyChanges(resultID, { + product_ids: { operation: "ADD", id: relatedRecordID } + }); + + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.data.length, 1, + "one2many should be a list of length 1"); + assert.strictEqual(record.data.product_ids.data[0].data.name, "xpod", + "one2many should have correct data"); + model.destroy(); + }); + + QUnit.test('dates are properly loaded and parsed (record)', async function (assert) { + assert.expect(2); + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['date'], + fields: this.data.partner.fields, + modelName: 'partner', + res_id: 1, + }; + + await model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.ok(record.data.date instanceof moment, + "fetched date field should have been formatted"); + }); + + params.res_id = 2; + + await model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.date, false, + "unset date field should be false"); + }); + model.destroy(); + }); + + QUnit.test('dates are properly loaded and parsed (list)', async function (assert) { + assert.expect(2); + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['date'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'list', + }; + + await model.load(params).then(function (resultID) { + var record = model.get(resultID); + var firstRecord = record.data[0]; + var secondRecord = record.data[1]; + assert.ok(firstRecord.data.date instanceof moment, + "fetched date field should have been formatted"); + assert.strictEqual(secondRecord.data.date, false, + "if date is not set, it should be false"); + }); + model.destroy(); + }); + + QUnit.test('dates are properly loaded and parsed (default_get)', async function (assert) { + assert.expect(1); + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['date'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'record', + }; + + await model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.date, false, "date default value should be false"); + }); + model.destroy(); + }); + + QUnit.test('default_get on x2many may return a list of ids', async function (assert) { + assert.expect(1); + + this.data.partner.fields.category.default = [12, 14]; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['category'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'record', + }; + + await model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.ok(_.isEqual(record.data.category.res_ids, [12, 14]), + "category field should have correct default value"); + }); + + model.destroy(); + }); + + QUnit.test('default_get: fetch many2one with default (empty & not) inside x2manys', async function (assert) { + assert.expect(3); + + this.data.partner.fields.category_m2o = { + type: 'many2one', + relation: 'partner_type', + }; + this.data.partner.fields.o2m = { + string: "O2M", type: 'one2many', relation: 'partner', default: [ + [6, 0, []], + [0, 0, { category_m2o: false, o2m: [] }], + [0, 0, { category_m2o: 12, o2m: [] }], + ], + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['o2m'], + fields: this.data.partner.fields, + fieldsInfo: { + form: { + o2m: { + relatedFields: this.data.partner.fields, + fieldsInfo: { + list: { + category_m2o: { + relatedFields: { display_name: {} }, + }, + }, + }, + viewType: 'list', + }, + }, + }, + modelName: 'partner', + type: 'record', + viewType: 'form', + }; + + var resultID = await model.load(params); + var record = model.get(resultID); + assert.strictEqual(record.data.o2m.count, 2, "o2m field should contain 2 records"); + assert.strictEqual(record.data.o2m.data[0].data.category_m2o, false, + "first category field should be empty"); + assert.strictEqual(record.data.o2m.data[1].data.category_m2o.data.display_name, "gold", + "second category field should have been correctly fetched"); + + model.destroy(); + }); + + QUnit.test('default_get: fetch x2manys inside x2manys', async function (assert) { + assert.expect(3); + + this.data.partner.fields.o2m = { + string: "O2M", type: 'one2many', relation: 'partner', default: [[6, 0, [1]]], + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['o2m'], + fields: this.data.partner.fields, + fieldsInfo: { + form: { + o2m: { + relatedFields: this.data.partner.fields, + fieldsInfo: { + list: { + category: { + relatedFields: { display_name: {} }, + }, + }, + }, + viewType: 'list', + }, + }, + }, + modelName: 'partner', + type: 'record', + viewType: 'form', + }; + + var resultID = await model.load(params); + var record = model.get(resultID); + assert.strictEqual(record.data.o2m.count, 1, "o2m field should contain 1 record"); + var categoryList = record.data.o2m.data[0].data.category; + assert.strictEqual(categoryList.count, 1, + "category field should contain 1 record"); + assert.strictEqual(categoryList.data[0].data.display_name, + 'gold', "category records should have been fetched"); + + model.destroy(); + }); + + QUnit.test('contexts and domains can be properly fetched', async function (assert) { + assert.expect(8); + + this.data.partner.fields.product_id.context = "{'hello': 'world', 'test': foo}"; + this.data.partner.fields.product_id.domain = "[['hello', 'like', 'world'], ['test', 'like', foo]]"; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + this.params.fieldNames = ['product_id', 'foo']; + + var resultID = await model.load(this.params); + var recordPartner = model.get(resultID); + assert.strictEqual(typeof recordPartner.getContext, "function", + "partner record should have a getContext function"); + assert.strictEqual(typeof recordPartner.getDomain, "function", + "partner record should have a getDomain function"); + assert.deepEqual(recordPartner.getContext(), {}, + "asking for a context without a field name should fetch the session/user/view context"); + assert.deepEqual(recordPartner.getDomain(), [], + "asking for a domain without a field name should fetch the session/user/view domain"); + assert.deepEqual( + recordPartner.getContext({ fieldName: "product_id" }), + { hello: "world", test: "gnap" }, + "asking for a context with a field name should fetch the field context (evaluated)"); + assert.deepEqual( + recordPartner.getDomain({ fieldName: "product_id" }), + [["hello", "like", "world"], ["test", "like", "gnap"]], + "asking for a domain with a field name should fetch the field domain (evaluated)"); + model.destroy(); + + // Try again with xml override of field domain and context + model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + this.params.fieldsInfo = { + default: { + foo: {}, + product_id: { + context: "{'hello2': 'world', 'test2': foo}", + domain: "[['hello2', 'like', 'world'], ['test2', 'like', foo]]", + }, + } + }; + + resultID = await model.load(this.params); + recordPartner = model.get(resultID); + assert.deepEqual( + recordPartner.getContext({ fieldName: "product_id" }), + { hello2: "world", test2: "gnap" }, + "field context should have been overridden by xml attribute"); + assert.deepEqual( + recordPartner.getDomain({ fieldName: "product_id" }), + [["hello2", "like", "world"], ["test2", "like", "gnap"]], + "field domain should have been overridden by xml attribute"); + model.destroy(); + }); + + QUnit.test('dont write on readonly fields (write and create)', async function (assert) { + assert.expect(6); + + this.params.fieldNames = ['foo', 'bar']; + this.data.partner.fields.foo.onChange = true; + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + }; + this.params.fieldsInfo = { + default: { + foo: {}, + bar: { + modifiers: { + readonly: true, + }, + }, + } + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], { foo: "verylongstring" }, + "should only save foo field"); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], { foo: "anotherverylongstring" }, + "should only save foo field"); + } + return this._super(route, args); + }, + }); + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.bar, 2, + "should be initialized with correct value"); + + await model.notifyChanges(resultID, { foo: "verylongstring" }); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 14, + "should be changed with correct value"); + + await model.save(resultID); + + // start again, but with a new record + delete this.params.res_id; + resultID = await model.load(this.params); + record = model.get(resultID); + assert.strictEqual(record.data.bar, 0, + "should be initialized with correct value (0 as integer)"); + + await model.notifyChanges(resultID, { foo: "anotherverylongstring" }); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 21, + "should be changed with correct value"); + + await model.save(resultID); + model.destroy(); + }); + + QUnit.test('dont write on readonly fields unless save attribute is set', async function (assert) { + assert.expect(6); + + this.params.fieldNames = ['foo', 'bar']; + this.data.partner.fields.foo.onChange = true; + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + }; + this.params.fieldsInfo = { + default: { + foo: {}, + bar: { + modifiers: { + readonly: true, + }, + force_save: true, + }, + } + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], { bar: 14, foo: "verylongstring" }, + "should only save foo field"); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], { bar: 21, foo: "anotherverylongstring" }, + "should only save foo field"); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.strictEqual(record.data.bar, 2, + "should be initialized with correct value"); + + await model.notifyChanges(resultID, { foo: "verylongstring" }); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 14, + "should be changed with correct value"); + + await model.save(resultID); + + // start again, but with a new record + delete this.params.res_id; + resultID = await model.load(this.params); + record = model.get(resultID); + assert.strictEqual(record.data.bar, 0, + "should be initialized with correct value (0 as integer)"); + + await model.notifyChanges(resultID, { foo: "anotherverylongstring" }); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 21, + "should be changed with correct value"); + + await model.save(resultID); + model.destroy(); + }); + + QUnit.test('default_get with one2many values', async function (assert) { + assert.expect(1); + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return Promise.resolve({ + product_ids: [[0, 0, { "name": "xdroid" }]] + }); + } + return this._super(route, args); + }, + }); + var params = { + fieldNames: ['product_ids'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'record', + fieldsInfo: { + form: { + product_ids: { + fieldsInfo: { + default: { name: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + }, + }, + viewType: 'form', + }; + var resultID = await model.load(params); + assert.strictEqual(typeof resultID, 'string', "result should be a valid id"); + model.destroy(); + }); + + QUnit.test('call makeRecord with a pre-fetched many2one field', async function (assert) { + assert.expect(3); + var rpcCount = 0; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + model.makeRecord('coucou', [{ + name: 'partner_id', + relation: 'partner', + type: 'many2one', + value: [1, 'first partner'], + }], { + partner_id: { + options: { + no_open: true, + }, + }, + }).then(function (recordID) { + var record = model.get(recordID); + assert.deepEqual(record.fieldsInfo.default.partner_id, { options: { no_open: true } }, + "makeRecord should have generated the fieldsInfo"); + assert.deepEqual(record.data.partner_id.data, { id: 1, display_name: 'first partner' }, + "many2one should contain the partner with id 1"); + assert.strictEqual(rpcCount, 0, "makeRecord should not have done any rpc"); + }); + model.destroy(); + }); + + QUnit.test('call makeRecord with a many2many field', async function (assert) { + assert.expect(5); + var rpcCount = 0; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + var recordID = await model.makeRecord('coucou', [{ + name: 'partner_ids', + fields: [{ + name: 'id', + type: 'integer', + }, { + name: 'display_name', + type: 'char', + }], + relation: 'partner', + type: 'many2many', + value: [1, 2], + }]); + var record = model.get(recordID); + assert.deepEqual(record.fieldsInfo.default.partner_ids, {}, + "makeRecord should have generated the fieldsInfo"); + assert.strictEqual(record.data.partner_ids.count, 2, + "there should be 2 elements in the many2many"); + assert.strictEqual(record.data.partner_ids.data.length, 2, + "many2many should be a list of length 2"); + assert.deepEqual(record.data.partner_ids.data[0].data, { id: 1, display_name: 'first partner' }, + "many2many should contain the partner with id 1"); + assert.strictEqual(rpcCount, 1, "makeRecord should have done 1 rpc"); + model.destroy(); + }); + + QUnit.test('call makeRecord with a pre-fetched many2many field', async function (assert) { + assert.expect(5); + var rpcCount = 0; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + var recordID = await model.makeRecord('coucou', [{ + name: 'partner_ids', + fields: [{ + name: 'id', + type: 'integer', + }, { + name: 'display_name', + type: 'char', + }], + relation: 'partner', + type: 'many2many', + value: [{ + id: 1, + display_name: "first partner", + }, { + id: 2, + display_name: "second partner", + }], + }]); + var record = model.get(recordID); + assert.deepEqual(record.fieldsInfo.default.partner_ids, {}, + "makeRecord should have generated the fieldsInfo"); + assert.strictEqual(record.data.partner_ids.count, 2, + "there should be 2 elements in the many2many"); + assert.strictEqual(record.data.partner_ids.data.length, 2, + "many2many should be a list of length 2"); + assert.deepEqual(record.data.partner_ids.data[0].data, { id: 1, display_name: 'first partner' }, + "many2many should contain the partner with id 1"); + assert.strictEqual(rpcCount, 0, "makeRecord should not have done any rpc"); + model.destroy(); + }); + + QUnit.test('call makeRecord with a selection field', async function (assert) { + assert.expect(4); + var rpcCount = 0; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + + var recordID = await model.makeRecord('partner', [{ + name: 'status', + string: 'Status', + type: 'selection', + selection: [['draft', 'Draft'], ['done', 'Done'], ['failed', 'Failed']], + value: 'done', + }]); + var record = model.get(recordID); + assert.deepEqual(record.fieldsInfo.default.status, {}, + "makeRecord should have generated the fieldsInfo"); + assert.strictEqual(record.data.status, 'done', + "should have a value 'done'"); + assert.strictEqual(record.fields.status.selection.length, 3, + "should have 3 keys for selection"); + assert.strictEqual(rpcCount, 0, "makeRecord should have done 0 rpc"); + model.destroy(); + }); + + QUnit.test('call makeRecord with a reference field', async function (assert) { + assert.expect(2); + let rpcCount = 0; + + const model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + const field = this.data.partner.fields.reference; + const recordID = await model.makeRecord('coucou', [{ + name: 'reference', + type: 'reference', + selection: field.selection, + value: 'product,37', + }]); + const record = model.get(recordID); + assert.deepEqual(record.data.reference.data, { id: 37, display_name: 'xphone' }); + assert.strictEqual(rpcCount, 1); + + model.destroy(); + }); + + QUnit.test('check id, active_id, active_ids, active_model values in record\'s context', async function (assert) { + assert.expect(2); + + this.data.partner.fields.product_id.context = "{'id': id, 'active_id': active_id, 'active_ids': active_ids, 'active_model': active_model}"; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + this.params.fieldNames = ['product_id']; + + var resultID = await model.load(this.params); + var recordPartner = model.get(resultID); + assert.deepEqual( + recordPartner.getContext({ fieldName: "product_id" }), + { id: 2, active_id: 2, active_ids: [2], active_model: "partner" }, + "wrong values for id, active_id, active_ids or active_model"); + + // Try again without record + this.params.res_id = undefined; + + resultID = await model.load(this.params); + recordPartner = model.get(resultID); + assert.deepEqual( + recordPartner.getContext({ fieldName: "product_id" }), + { id: false, active_id: false, active_ids: [], active_model: "partner" }, + "wrong values for id, active_id, active_ids or active_model. Have to be defined even if there is no record."); + + model.destroy(); + }); + + QUnit.test('load model with many2many field properly fetched', async function (assert) { + assert.expect(2); + + this.params.fieldNames = ['category']; + this.params.res_id = 1; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super(route, args); + }, + }); + + await model.load(this.params); + assert.verifySteps(['read'], + "there should be only one read"); + model.destroy(); + }); + + QUnit.test('data should contain all fields in view, default being false', async function (assert) { + assert.expect(1); + + this.data.partner.fields.product_ids.default = [ + [6, 0, []], + [0, 0, { name: 'new' }], + ]; + this.data.product.fields.date = { string: "Date", type: "date" }; + + var params = { + fieldNames: ['product_ids'], + modelName: 'partner', + fields: this.data.partner.fields, + fieldsInfo: { + form: { + product_ids: { + relatedFields: this.data.product.fields, + fieldsInfo: { list: { name: {}, date: {} } }, + viewType: 'list', + } + }, + }, + res_id: undefined, + type: 'record', + viewType: 'form', + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + await model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.product_ids.data[0].data.date, false, + "date value should be in data, and should be false"); + }); + + model.destroy(); + }); + + QUnit.test('changes are discarded when reloading from a new record', async function (assert) { + // practical use case: click on 'Create' to open a form view in edit + // mode (new record), click on 'Discard', then open an existing record + assert.expect(2); + + this.data.partner.fields.foo.default = 'default'; + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + // load a new record (default_get) + var params = _.extend(this.params, { + res_id: undefined, + type: 'record', + fieldNames: ['foo'], + }); + var resultID = await model.load(params); + var record = model.get(resultID); + assert.strictEqual(record.data.foo, 'default', + "should be the default value"); + + // reload with id 2 + resultID = await model.reload(record.id, { currentId: 2 }); + record = model.get(resultID); + assert.strictEqual(record.data.foo, 'gnap', + "should be the value of record 2"); + + model.destroy(); + }); + + QUnit.test('has a proper evaluation context', async function (assert) { + assert.expect(6); + + const unpatchDate = testUtils.mock.patchDate(1997, 0, 9, 12, 0, 0); + this.params.fieldNames = Object.keys(this.data.partner.fields); + this.params.res_id = 1; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + const { evalContext } = model.get(resultID); + assert.strictEqual(typeof evalContext.datetime, "object"); + assert.strictEqual(typeof evalContext.relativedelta, "object"); + assert.strictEqual(typeof evalContext.time, "object"); + assert.strictEqual(typeof evalContext.context_today, "function"); + assert.strictEqual(typeof evalContext.tz_offset, "function"); + const blackListedKeys = [ + "time", + "datetime", + "relativedelta", + "context_today", + "tz_offset", + ]; + // Remove uncomparable values from the evaluation context + for (const key of blackListedKeys) { + delete evalContext[key]; + } + assert.deepEqual(evalContext, { + active: true, + active_id: 1, + active_ids: [1], + active_model: "partner", + bar: 1, + category: [12], + current_company_id: false, + current_date: moment().format('YYYY-MM-DD'), + today: moment().format('YYYY-MM-DD'), + now: moment().utc().format('YYYY-MM-DD HH:mm:ss'), + date: "2017-01-25", + display_name: "first partner", + foo: "blip", + id: 1, + product_id: 37, + product_ids: [], + qux: false, + reference: false, + total: 0, + x_active: true, + }, "should use the proper eval context"); + model.destroy(); + unpatchDate(); + }); + + QUnit.test('x2manys in contexts and domains are correctly evaluated', async function (assert) { + assert.expect(4); + + this.data.partner.records[0].product_ids = [37, 41]; + this.params.fieldNames = Object.keys(this.data.partner.fields); + this.params.fieldsInfo = { + form: { + qux: { + context: "{'category': category, 'product_ids': product_ids}", + domain: "[['id', 'in', category], ['id', 'in', product_ids]]", + relatedFields: this.data.partner.fields, + }, + category: { + relatedFields: this.data.partner_type.fields, + }, + product_ids: { + relatedFields: this.data.product.fields, + }, + }, + }; + this.params.viewType = 'form'; + this.params.res_id = 1; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + var context = record.getContext({ fieldName: 'qux' }); + var domain = record.getDomain({ fieldName: 'qux' }); + + assert.deepEqual(context, { + category: [12], + product_ids: [37, 41], + }, "x2many values in context manipulated client-side should be lists of ids"); + assert.strictEqual(JSON.stringify(context), + "{\"category\":[[6,false,[12]]],\"product_ids\":[[4,37,false],[4,41,false]]}", + "x2many values in context sent to the server should be commands"); + assert.deepEqual(domain, [ + ['id', 'in', [12]], + ['id', 'in', [37, 41]], + ], "x2many values in domains should be lists of ids"); + assert.strictEqual(JSON.stringify(domain), + "[[\"id\",\"in\",[12]],[\"id\",\"in\",[37,41]]]", + "x2many values in domains should be lists of ids"); + model.destroy(); + }); + + QUnit.test('fetch references in list, with not too many rpcs', async function (assert) { + assert.expect(5); + + this.data.partner.records[0].reference = 'product,37'; + this.data.partner.records[1].reference = 'product,41'; + + this.params.fieldNames = ['reference']; + this.params.domain = []; + this.params.groupedBy = []; + this.params.res_id = undefined; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(route); + if (route === "/web/dataset/call_kw/product/name_get") { + assert.deepEqual(args.args, [[37, 41]], + "the name_get should contain the product ids"); + } + return this._super(route, args); + }, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + + assert.strictEqual(record.data[0].data.reference.data.display_name, "xphone", + "name_get should have been correctly fetched"); + assert.verifySteps(["/web/dataset/search_read", "/web/dataset/call_kw/product/name_get"], + "should have done 2 rpc (searchread and name_get for product)"); + model.destroy(); + }); + + QUnit.test('reload a new record', async function (assert) { + assert.expect(6); + + this.params.context = {}; + this.params.fieldNames = ['product_id', 'category', 'product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super(route, args); + }, + }); + + var recordID = await model.load(this.params); + recordID = await model.reload(recordID); + assert.verifySteps(['onchange', 'onchange']); + var record = model.get(recordID); + assert.strictEqual(record.data.product_id, false, + "m2o default value should be false"); + assert.deepEqual(record.data.product_ids.data, [], + "o2m default should be []"); + assert.deepEqual(record.data.category.data, [], + "m2m default should be []"); + + model.destroy(); + }); + + QUnit.test('default_get with value false for a one2many', async function (assert) { + assert.expect(1); + + this.data.partner.fields.product_ids.default = false; + this.params.fieldNames = ['product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.deepEqual(record.data.product_ids.data, [], "o2m default should be []"); + + model.destroy(); + }); + + QUnit.test('only x2many lists (static) should be sorted client-side', async function (assert) { + assert.expect(1); + + this.params.modelName = 'partner_type'; + this.params.res_id = undefined; + this.params.orderedBy = [{ name: 'display_name', asc: true }]; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route) { + if (route === '/web/dataset/search_read') { + // simulate randomn sort form the server + return Promise.resolve({ + length: 3, + records: [ + { id: 12, display_name: "gold", date: "2017-01-25" }, + { id: 15, display_name: "bronze" }, + { id: 14, display_name: "silver" }, + ], + }); + } + return this._super.apply(this, arguments); + }, + }); + + var resultID = await model.load(this.params); + var list = model.get(resultID); + assert.deepEqual(_.map(list.data, 'res_id'), [12, 15, 14], + "should have kept the order from the server"); + model.destroy(); + }); + + QUnit.test('onchange on a boolean field', async function (assert) { + assert.expect(2); + + var newFields = { + foobool: { + type: 'boolean', + string: 'foobool', + }, + foobool2: { + type: 'boolean', + string: 'foobool2', + }, + }; + _.extend(this.data.partner.fields, newFields); + + this.data.partner.fields.foobool.onChange = true; + this.data.partner.onchanges.foobool = function (obj) { + if (obj.foobool) { + obj.foobool2 = true; + } + }; + + this.data.partner.records[0].foobool = false; + this.data.partner.records[0].foobool2 = true; + + this.params.res_id = 1; + this.params.fieldNames = ['foobool', 'foobool2']; + this.params.fields = this.data.partner.fields; + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var record = model.get(resultID); + await model.notifyChanges(resultID, { foobool2: false }); + record = model.get(resultID); + assert.strictEqual(record.data.foobool2, false, "foobool2 field should be false"); + await model.notifyChanges(resultID, { foobool: true }); + record = model.get(resultID); + assert.strictEqual(record.data.foobool2, true, "foobool2 field should be true"); + model.destroy(); + }); + + QUnit.test('notifyChange DELETE_ALL on a one2many', async function (assert) { + assert.expect(5); + + this.data.partner.records[1].product_ids = [37, 38]; + this.params.fieldNames = ['product_ids']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var o2mParams = { + modelName: 'product', + fields: this.data.product.fields, + }; + + var resultID = await model.load(this.params); + var newRecordID = await model.load(o2mParams); + var record = model.get(resultID); + var x2mListID = record.data.product_ids.id; + + assert.strictEqual(record.data.product_ids.count, 2, + "there should be two records in the relation"); + + await model.notifyChanges(resultID, {product_ids: {operation: 'ADD', id: newRecordID}}); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'ADD', id: newRecordID, + }], "_changes should be correct"); + + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 3, + "there should be three records in the relation"); + + await model.notifyChanges(resultID, {product_ids: {operation: 'DELETE_ALL'}}); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + id: 37, + operation: "DELETE" + }, { + id: 38, + operation: "DELETE" + }], "_changes should contain the two 'DELETE' operations"); + + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 0, + "there should be no more records in the relation"); + model.destroy(); + }); + + QUnit.test('notifyChange MULTI on a one2many', async function (assert) { + assert.expect(4); + + this.data.partner.records[1].product_ids = [37, 38]; + this.params.fieldNames = ['product_ids']; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var o2mParams = { + modelName: 'product', + fields: this.data.product.fields, + }; + + var resultID = await model.load(this.params); + var newRecordID = await model.load(o2mParams); + var record = model.get(resultID); + var x2mListID = record.data.product_ids.id; + + assert.strictEqual(record.data.product_ids.count, 2, + "there should be two records in the relation"); + + await model.notifyChanges(resultID, {product_ids: { + operation: 'MULTI', + commands: [{ + operation: 'DELETE_ALL' + }, { + operation: 'ADD', + id: newRecordID + }] + }}); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + id: 37, + operation: "DELETE" + }, { + id: 38, + operation: "DELETE" + }, { + operation: 'ADD', id: newRecordID, + }], "_changes should be correct"); + + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 1, + "there should be one record in the relation"); + + assert.strictEqual(record.data.product_ids.data[0].id, newRecordID, + "the id should match"); + }); + + QUnit.test('notifyChange MULTI on a many2many', async function (assert) { + assert.expect(3); + + this.params.fieldsInfo = { + default: { + category: { + fieldsInfo: {default: {some_char: { context: "{'a': parent.foo}"}}}, + relatedFields: {some_char: {type: "char"}}, + viewType: 'default', + }, + foo: {}, + }, + }; + + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + + var resultID = await model.load(this.params); + var changes = { + category: { + operation: 'MULTI', + commands: [{ + operation: 'ADD_M2M', + ids: [{id: 23}, {id: 24}, {id: 25}] + }, { + operation: 'ADD_M2M', + ids: [{id: 26}] + }] + } + }; + await model.notifyChanges(resultID, changes); + var record = model.get(resultID); + var categoryRecord = record.data.category; + + assert.strictEqual(categoryRecord.data.length, 4, + "there should 2 records in the relation"); + + await model.notifyChanges(resultID, {category: { + operation: 'MULTI', + commands: [{ + operation: 'DELETE_ALL' + }, { + operation: 'ADD_M2M', + ids: [{id: 27}] + }] + }}); + record = model.get(resultID); + categoryRecord = record.data.category; + assert.strictEqual(categoryRecord.data.length, 1, + "there should 1 record in the relation"); + + assert.strictEqual(record.data.category.data[0].data.id, 27, + "the id should match"); + + model.destroy(); + }); + + QUnit.test('identify correct active field', async function(assert) { + assert.expect(4); + var model = await createModel({ + Model: BasicModel, + data: this.data, + }); + // check that active field is returned if present + this.params.res_id = 37; + this.params.modelName = 'product' + this.params.fields = this.data.product.fields; + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.equal(model.getActiveField(record), 'active', 'should have returned "active" field name'); + // check that active field is not returned if not present + this.params.res_id = 42; + this.params.modelName = 'partner_title'; + this.params.fields = this.data.partner_title.fields; + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.equal(model.getActiveField(record), undefined, 'should not have returned any field name'); + // check that x_active field is returned if x_active present + this.params.res_id = 12; + this.params.modelName = 'partner_type'; + this.params.fields = this.data.partner_type.fields; + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.equal(model.getActiveField(record), 'x_active', 'should have returned "x_active" field name'); + + // check that active field is returned if both active and x_active present + this.params.res_id = 1; + this.params.modelName = 'partner'; + this.params.fields = this.data.partner.fields; + var resultID = await model.load(this.params); + var record = model.get(resultID); + assert.equal(model.getActiveField(record), 'active', 'should have returned "active" field name'); + }); + }); +}); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js new file mode 100644 index 00000000..3fb17d27 --- /dev/null +++ b/addons/web/static/tests/views/calendar_tests.js @@ -0,0 +1,3883 @@ +odoo.define('web.calendar_tests', function (require) { +"use strict"; + +const AbstractField = require('web.AbstractField'); +const fieldRegistry = require('web.field_registry'); +var AbstractStorageService = require('web.AbstractStorageService'); +var CalendarView = require('web.CalendarView'); +var CalendarRenderer = require('web.CalendarRenderer'); +var Dialog = require('web.Dialog'); +var ViewDialogs = require('web.view_dialogs'); +var fieldUtils = require('web.field_utils'); +var mixins = require('web.mixins'); +var RamStorage = require('web.RamStorage'); +var testUtils = require('web.test_utils'); +var session = require('web.session'); + +var createActionManager = testUtils.createActionManager; + +CalendarRenderer.include({ + getAvatars: function () { + var res = this._super.apply(this, arguments); + for (var k in res) { + res[k] = res[k].replace(/src="([^"]+)"/, 'data-src="\$1"'); + } + return res; + } +}); + + +var createCalendarView = testUtils.createCalendarView; + +// 2016-12-12 08:00:00 +var initialDate = new Date(2016, 11, 12, 8, 0, 0); +initialDate = new Date(initialDate.getTime() - initialDate.getTimezoneOffset()*60*1000); + +function _preventScroll(ev) { + ev.stopImmediatePropagation(); +} + +QUnit.module('Views', { + beforeEach: function () { + window.addEventListener('scroll', _preventScroll, true); + session.uid = -1; // TO CHECK + this.data = { + event: { + fields: { + id: {string: "ID", type: "integer"}, + user_id: {string: "user", type: "many2one", relation: 'user', default: session.uid}, + partner_id: {string: "user", type: "many2one", relation: 'partner', related: 'user_id.partner_id', default: 1}, + name: {string: "name", type: "char"}, + start_date: {string: "start date", type: "date"}, + stop_date: {string: "stop date", type: "date"}, + start: {string: "start datetime", type: "datetime"}, + stop: {string: "stop datetime", type: "datetime"}, + delay: {string: "delay", type: "float"}, + allday: {string: "allday", type: "boolean"}, + partner_ids: {string: "attendees", type: "one2many", relation: 'partner', default: [[6, 0, [1]]]}, + type: {string: "type", type: "integer"}, + event_type_id: {string: "Event_Type", type: "many2one", relation: 'event_type'}, + color: {string: "Color", type: "integer", related: 'event_type_id.color'}, + }, + records: [ + {id: 1, user_id: session.uid, partner_id: 1, name: "event 1", start: "2016-12-11 00:00:00", stop: "2016-12-11 00:00:00", allday: false, partner_ids: [1,2,3], type: 1}, + {id: 2, user_id: session.uid, partner_id: 1, name: "event 2", start: "2016-12-12 10:55:05", stop: "2016-12-12 14:55:05", allday: false, partner_ids: [1,2], type: 3}, + {id: 3, user_id: 4, partner_id: 4, name: "event 3", start: "2016-12-12 15:55:05", stop: "2016-12-12 16:55:05", allday: false, partner_ids: [1], type: 2}, + {id: 4, user_id: session.uid, partner_id: 1, name: "event 4", start: "2016-12-14 15:55:05", stop: "2016-12-14 18:55:05", allday: true, partner_ids: [1], type: 2}, + {id: 5, user_id: 4, partner_id: 4, name: "event 5", start: "2016-12-13 15:55:05", stop: "2016-12-20 18:55:05", allday: false, partner_ids: [2,3], type: 2}, + {id: 6, user_id: session.uid, partner_id: 1, name: "event 6", start: "2016-12-18 08:00:00", stop: "2016-12-18 09:00:00", allday: false, partner_ids: [3], type: 3}, + {id: 7, user_id: session.uid, partner_id: 1, name: "event 7", start: "2016-11-14 08:00:00", stop: "2016-11-16 17:00:00", allday: false, partner_ids: [2], type: 1}, + ], + check_access_rights: function () { + return Promise.resolve(true); + } + }, + user: { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + partner_id: {string: "partner", type: "many2one", relation: 'partner'}, + image: {string: "image", type: "integer"}, + }, + records: [ + {id: session.uid, display_name: "user 1", partner_id: 1}, + {id: 4, display_name: "user 4", partner_id: 4}, + ] + }, + partner: { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + image: {string: "image", type: "integer"}, + }, + records: [ + {id: 1, display_name: "partner 1", image: 'AAA'}, + {id: 2, display_name: "partner 2", image: 'BBB'}, + {id: 3, display_name: "partner 3", image: 'CCC'}, + {id: 4, display_name: "partner 4", image: 'DDD'} + ] + }, + event_type: { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + color: {string: "Color", type: "integer"}, + }, + records: [ + {id: 1, display_name: "Event Type 1", color: 1}, + {id: 2, display_name: "Event Type 2", color: 2}, + {id: 3, display_name: "Event Type 3 (color 4)", color: 4}, + ] + }, + filter_partner: { + fields: { + id: {string: "ID", type: "integer"}, + user_id: {string: "user", type: "many2one", relation: 'user'}, + partner_id: {string: "partner", type: "many2one", relation: 'partner'}, + }, + records: [ + {id: 1, user_id: session.uid, partner_id: 1}, + {id: 2, user_id: session.uid, partner_id: 2}, + {id: 3, user_id: 4, partner_id: 3} + ] + }, + }; + }, + afterEach: function () { + window.removeEventListener('scroll', _preventScroll, true); + }, +}, function () { + + QUnit.module('CalendarView'); + + var archs = { + "event,false,form": + '<form>'+ + '<field name="name"/>'+ + '<field name="allday"/>'+ + '<group attrs=\'{"invisible": [["allday","=",True]]}\' >'+ + '<field name="start"/>'+ + '<field name="stop"/>'+ + '</group>'+ + '<group attrs=\'{"invisible": [["allday","=",False]]}\' >'+ + '<field name="start_date"/>'+ + '<field name="stop_date"/>'+ + '</group>'+ + '</form>', + "event,1,form": + '<form>' + + '<field name="allday" invisible="1"/>' + + '<field name="start" attrs=\'{"invisible": [["allday","=",false]]}\'/>' + + '<field name="stop" attrs=\'{"invisible": [["allday","=",true]]}\'/>' + + '</form>', + }; + + QUnit.test('simple calendar rendering', async function (assert) { + assert.expect(24); + + this.data.event.records.push({ + id: 8, + user_id: session.uid, + partner_id: false, + name: "event 7", + start: "2016-12-18 09:00:00", + stop: "2016-12-18 10:00:00", + allday: false, + partner_ids: [2], + type: 1 + }); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '<field name="partner_id" filters="1" invisible="1"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length, + "should instance of fullcalendar"); + + var $sidebar = calendar.$('.o_calendar_sidebar'); + + // test view scales + assert.containsN(calendar, '.fc-event', 9, + "should display 9 events on the week (4 event + 5 days event)"); + assert.containsN($sidebar, 'tr:has(.ui-state-active) td', 7, + "week scale should highlight 7 days in mini calendar"); + + await testUtils.dom.click(calendar.$buttons.find('.o_calendar_button_day')); // display only one day + assert.containsN(calendar, '.fc-event', 2, "should display 2 events on the day"); + assert.containsOnce($sidebar, '.o_selected_range', + "should highlight the target day in mini calendar"); + + await testUtils.dom.click(calendar.$buttons.find('.o_calendar_button_month')); // display all the month + assert.containsN(calendar, '.fc-event', 7, + "should display 7 events on the month (5 events + 2 week event - 1 'event 6' is filtered + 1 'Undefined event')"); + assert.containsN($sidebar, 'td a', 31, + "month scale should highlight all days in mini calendar"); + + // test filters + assert.containsN($sidebar, '.o_calendar_filter', 2, "should display 2 filters"); + + var $typeFilter = $sidebar.find('.o_calendar_filter:has(h5:contains(user))'); + assert.ok($typeFilter.length, "should display 'user' filter"); + assert.containsN($typeFilter, '.o_calendar_filter_item', 3, "should display 3 filter items for 'user'"); + + // filters which has no value should show with string "Undefined", should not have any user image and should show at the last + assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last').data('value'), false, "filters having false value should be displayed at last in filter items"); + assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last .o_cw_filter_title').text(), "Undefined", "filters having false value should display 'Undefined' string"); + assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last label img').length, 0, "filters having false value should not have any user image"); + + var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h5:contains(attendees))'); + assert.ok($attendeesFilter.length, "should display 'attendees' filter"); + assert.containsN($attendeesFilter, '.o_calendar_filter_item', 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)"); + assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter"); + + assert.containsN(calendar, '.fc-event', 7, + "should display 7 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)"); + await testUtils.dom.click(calendar.$('.o_calendar_filter input[type="checkbox"]').first()); + assert.containsN(calendar, '.fc-event', 4, "should now only display 4 event"); + await testUtils.dom.click(calendar.$('.o_calendar_filter input[type="checkbox"]').eq(1)); + assert.containsNone(calendar, '.fc-event', "should not display any event anymore"); + + // test search bar in filter + await testUtils.dom.click($sidebar.find('input[type="text"]')); + assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').length, 2,"should display 2 choices in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice + await testUtils.dom.click($('ul.ui-autocomplete li:first')); + assert.containsN($sidebar, '.o_calendar_filter:has(h5:contains(attendees)) .o_calendar_filter_item', 4, "should display 4 filter items for 'attendees'"); + await testUtils.dom.click($sidebar.find('input[type="text"]')); + assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').text(), "partner 4", "should display the last choice in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice + await testUtils.dom.click($sidebar.find('.o_calendar_filter_item .o_remove').first(), {allowInvisible: true}); + assert.ok($('.modal-footer button.btn:contains(Ok)').length, "should display the confirm message"); + await testUtils.dom.click($('.modal-footer button.btn:contains(Ok)')); + assert.containsN($sidebar, '.o_calendar_filter:has(h5:contains(attendees)) .o_calendar_filter_item', 3, "click on remove then should display 3 filter items for 'attendees'"); + calendar.destroy(); + }); + + QUnit.test('delete attribute on calendar doesn\'t show delete button in popover', async function (assert) { + assert.expect(2); + + const calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" ' + + 'string="Events" ' + + 'event_open_popup="true" ' + + 'date_start="start" ' + + 'date_stop="stop" ' + + 'all_day="allday" ' + + 'delete="0" ' + + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content')); + + assert.containsOnce(calendar, '.o_cw_popover', + "should open a popover clicking on event"); + assert.containsNone(calendar, '.o_cw_popover .o_cw_popover_delete', + "should not have the 'Delete' Button"); + + calendar.destroy(); + }); + + QUnit.test('breadcrumbs are updated with the displayed period', async function (assert) { + assert.expect(4); + + var archs = { + 'event,1,calendar': '<calendar date_start="start" date_stop="stop" all_day="allday"/>', + 'event,false,search': '<search></search>', + }; + + var actions = [{ + id: 1, + flags: { + initialDate: initialDate, + }, + name: 'Meetings Test', + res_model: 'event', + type: 'ir.actions.act_window', + views: [[1, 'calendar']], + }]; + + var actionManager = await createActionManager({ + actions: actions, + archs: archs, + data: this.data, + }); + + await actionManager.doAction(1); + await testUtils.nextTick(); + + // displays month mode by default + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), + 'Meetings Test (Dec 11 – 17, 2016)', "should display the current week"); + + // switch to day mode + await testUtils.dom.click($('.o_control_panel .o_calendar_button_day')); + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), + 'Meetings Test (December 12, 2016)', "should display the current day"); + + // switch to month mode + await testUtils.dom.click($('.o_control_panel .o_calendar_button_month')); + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), + 'Meetings Test (December 2016)', "should display the current month"); + + // switch to year mode + await testUtils.dom.click($('.o_control_panel .o_calendar_button_year')); + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), + 'Meetings Test (2016)', "should display the current year"); + + actionManager.destroy(); + }); + + QUnit.test('create and change events', async function (assert) { + assert.expect(28); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month"/>', + archs: archs, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], {name: 'event 4 modified'}, "should update the record"); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.ok(calendar.$('.fc-dayGridMonth-view').length, "should display in month mode"); + + // click on an existing event to open the formViewDialog + + await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content')); + + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + assert.ok(calendar.$('.o_cw_popover .o_cw_popover_edit').length, "popover should have an edit button"); + assert.ok(calendar.$('.o_cw_popover .o_cw_popover_delete').length, "popover should have a delete button"); + assert.ok(calendar.$('.o_cw_popover .o_cw_popover_close').length, "popover should have a close button"); + + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit')); + + assert.ok($('.modal-body').length, "should open the form view in dialog when click on event"); + + await testUtils.fields.editInput($('.modal-body input:first'), 'event 4 modified'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Save)')); + + assert.notOk($('.modal-body').length, "save button should close the modal"); + + // create a new event, quick create only + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + assert.ok($('.modal-sm').length, "should open the quick create dialog"); + + await testUtils.fields.editInput($('.modal-body input:first'), 'new event in quick create'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Create)')); + + assert.strictEqual(calendar.$('.fc-event:contains(new event in quick create)').length, 1, "should display the new record after quick create"); + assert.containsN(calendar, 'td.fc-event-container[colspan]', 2, "should the new record have only one day"); + + // create a new event, quick create only (validated by pressing enter key) + + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + assert.ok($('.modal-sm').length, "should open the quick create dialog"); + + await testUtils.fields.editInput($('.modal-body input:first'), + 'new event in quick create validated by pressing enter key.'); + $('.modal-body input:first') + .val('new event in quick create validated by pressing enter key.') + .trigger($.Event('keyup', {keyCode: $.ui.keyCode.ENTER})) + .trigger($.Event('keyup', {keyCode: $.ui.keyCode.ENTER})); + await testUtils.nextTick(); + assert.containsOnce(calendar, '.fc-event:contains(new event in quick create validated by pressing enter key.)', + "should display the new record by pressing enter key"); + + + // create a new event and edit it + + $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)'); + + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + assert.strictEqual($('.modal-sm').length, 1, "should open the quick create dialog"); + + testUtils.fields.editInput($('.modal-body input:first'), 'coucou'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Edit)')); + + assert.strictEqual($('.modal-lg .o_form_view').length, 1, "should open the slow create dialog"); + assert.strictEqual($('.modal-lg .modal-title').text(), "Create: Events", + "should use the string attribute as modal title"); + assert.strictEqual($('.modal-lg .o_form_view input[name="name"]').val(), "coucou", + "should have set the name from the quick create dialog"); + + await testUtils.dom.click($('.modal-lg button.btn:contains(Save)')); + + assert.strictEqual(calendar.$('.fc-event:contains(coucou)').length, 1, + "should display the new record with string attribute"); + + // create a new event with 2 days + + $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell.next(), "mousemove"); + testUtils.dom.triggerMouseEvent($cell.next(), "mouseup"); + await testUtils.nextTick(); + + testUtils.fields.editInput($('.modal-dialog input:first'), 'new event in quick create 2'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Edit)')); + + assert.strictEqual($('.modal-lg input:first').val(),'new event in quick create 2', + "should open the formViewDialog with default values"); + + await testUtils.dom.click($('.modal-lg button.btn:contains(Save)')); + + assert.notOk($('.modal').length, "should close dialogs"); + var $newevent2 = calendar.$('.fc-event:contains(new event in quick create 2)'); + assert.ok($newevent2.length, "should display the 2 days new record"); + assert.hasAttrValue($newevent2.closest('.fc-event-container'), + 'colspan', "2","the new record should have 2 days"); + + await testUtils.dom.click(calendar.$('.fc-event:contains(new event in quick create 2) .fc-content')); + var $popover_description = calendar.$('.o_cw_popover .o_cw_body .list-group-item'); + assert.strictEqual($popover_description.children()[1].textContent,'December 20-21, 2016', + "The popover description should indicate the correct range"); + assert.strictEqual($popover_description.children()[2].textContent,'(2 days)', + "The popover description should indicate 2 days"); + await testUtils.dom.click(calendar.$('.o_cw_popover .fa-close')); + + // delete the a record + + await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content')); + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_delete')); + assert.ok($('.modal-footer button.btn:contains(Ok)').length, "should display the confirm message"); + await testUtils.dom.click($('.modal-footer button.btn:contains(Ok)')); + assert.notOk(calendar.$('.fc-event:contains(event 4) .fc-content').length, "the record should be deleted"); + + assert.containsN(calendar, '.fc-event-container .fc-event', 10, "should display 10 events"); + // move to next month + await testUtils.dom.click(calendar.$buttons.find('.o_calendar_button_next')); + + assert.containsNone(calendar, '.fc-event-container .fc-event', "should display 0 events"); + + calendar.destroy(); + }); + + QUnit.test('quickcreate switching to actual create for required fields', async function (assert) { + assert.expect(4); + + var event = $.Event(); + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + return Promise.reject({ + message: { + code: 200, + data: {}, + message: "Odoo server error", + }, + event: event + }); + } + return this._super(route, args); + }, + }); + + // create a new event + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + assert.strictEqual($('.modal-sm .modal-title').text(), 'Create: Events', + "should open the quick create dialog"); + + await testUtils.fields.editInput($('.modal-body input:first'), 'new event in quick create'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Create)')); + await testUtils.nextTick(); + + // If the event is not default-prevented, a traceback will be raised, which we do not want + assert.ok(event.isDefaultPrevented(), "fail deferred event should have been default-prevented"); + + assert.strictEqual($('.modal-lg .modal-title').text(), 'Create: Events', + "should have switched to a bigger modal for an actual create rather than quickcreate"); + assert.strictEqual($('.modal-lg main .o_form_view.o_form_editable').length, 1, + "should open the full event form view in a dialog"); + + calendar.destroy(); + }); + + QUnit.test('open multiple event form at the same time', async function (assert) { + assert.expect(2); + + var prom = testUtils.makeTestPromise(); + var counter = 0; + testUtils.mock.patch(ViewDialogs.FormViewDialog, { + open: function () { + counter++; + this.options = _.omit(this.options, 'fields_view'); // force loadFieldView + return this._super.apply(this, arguments); + }, + loadFieldView: function () { + var self = this; + var args = arguments; + var _super = this._super; + return prom.then(function () { + return _super.apply(self, args); + }); + }, + }); + + var event = $.Event(); + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'quick_add="False" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + for (var i = 0; i < 5; i++) { + await testUtils.dom.triggerMouseEvent($cell, "mousedown"); + await testUtils.dom.triggerMouseEvent($cell, "mouseup"); + } + prom.resolve(); + await testUtils.nextTick(); + assert.equal(counter, 5, "there should had been 5 attemps to open a modal"); + assert.containsOnce($('body'), '.modal', "there should be only one open modal"); + + calendar.destroy(); + testUtils.mock.unpatch(ViewDialogs.FormViewDialog); + }); + + QUnit.test('create event with timezone in week mode European locale', async function (assert) { + assert.expect(5); + + this.data.event.records = []; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week">'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="allday"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + translateParameters: { // Avoid issues due to localization formats + time_format: "%H:%M:%S", + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.deepEqual(args.kwargs.context, { + "default_start": "2016-12-13 06:00:00", + "default_stop": "2016-12-13 08:00:00", + "default_allday": null + }, + "should send the context to create events"); + } + return this._super(route, args); + }, + }, {positionalClicks: true}); + + var top = calendar.$('.fc-axis:contains(8:00)').offset().top + 5; + var left = calendar.$('.fc-day:eq(2)').offset().left + 5; + + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + + testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove"); + + assert.strictEqual(calendar.$('.fc-content .fc-time').text(), "8:00 - 10:00", + "should display the time in the calendar sticker"); + + await testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup"); + await testUtils.nextTick(); + await testUtils.fields.editInput($('.modal input:first'), 'new event'); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + var $newevent = calendar.$('.fc-event:contains(new event)'); + + assert.strictEqual($newevent.find('.o_event_title').text(), "new event", + "should display the new event with title"); + + assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record, + { + display_name: "new event", + start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}), + stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}), + allday: false, + name: "new event", + id: 1 + }, + "the new record should have the utc datetime (quickCreate)"); + + // delete record + + await testUtils.dom.click($newevent); + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_delete')); + await testUtils.dom.click($('.modal button.btn-primary:contains(Ok)')); + assert.containsNone(calendar, '.fc-content', "should delete the record"); + + calendar.destroy(); + }); + + QUnit.test('default week start (US)', function (assert) { + // if not given any option, default week start is on Sunday + assert.expect(3); + var done = assert.async(); + + createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, [ + ["start","<=","2016-12-17 23:59:59"], + ["stop",">=","2016-12-11 00:00:00"] + ], + 'The domain to search events in should be correct'); + } + return this._super.apply(this, arguments); + } + }).then(function (calendar) { + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Sun 11", + "The first day of the week should be Sunday"); + assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sat 17", + "The last day of the week should be Saturday"); + calendar.destroy(); + done(); + }); + }); + + QUnit.test('European week start', function (assert) { + // the week start depends on the locale + assert.expect(3); + var done = assert.async(); + + createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initialDate, + }, + translateParameters: { + week_start: 1, + }, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, [ + ["start","<=","2016-12-18 23:59:59"], + ["stop",">=","2016-12-12 00:00:00"] + ], + 'The domain to search events in should be correct'); + } + return this._super.apply(this, arguments); + } + }).then(function (calendar) { + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Mon 12", + "The first day of the week should be Monday"); + assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sun 18", + "The last day of the week should be Sunday"); + calendar.destroy(); + done(); + }); + }); + + QUnit.test('week numbering', function (assert) { + // week number depends on the week start, which depends on the locale + // the calendar library uses numbers [0 .. 6], while Odoo uses [1 .. 7] + // so if the modulo is not done, the week number is incorrect + assert.expect(1); + var done = assert.async(); + + createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initialDate, + }, + translateParameters: { + week_start: 7, + }, + }).then(function (calendar) { + assert.strictEqual(calendar.$('.fc-week-number').text(), "Week 51", + "We should be on the 51st week"); + calendar.destroy(); + done(); + }); + }); + + QUnit.test('render popover', async function (assert) { + assert.expect(14); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week">'+ + '<field name="name" string="Custom Name"/>'+ + '<field name="partner_id"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + await testUtils.dom.click($('.fc-event:contains(event 4)')); + + assert.containsOnce(calendar, '.o_cw_popover', "should open a popover clicking on event"); + assert.strictEqual(calendar.$('.o_cw_popover .popover-header').text(), 'event 4', "popover should have a title 'event 4'"); + assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_edit', "popover should have an edit button"); + assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_delete', "popover should have a delete button"); + assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_close', "popover should have a close button"); + + assert.strictEqual(calendar.$('.o_cw_popover .list-group-item:first b.text-capitalize').text(), 'Wednesday, December 14, 2016', "should display date 'Wednesday, December 14, 2016'"); + assert.containsN(calendar, '.o_cw_popover .o_cw_popover_fields_secondary .list-group-item', 2, "popover should have a two fields"); + + assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:first .o_field_char', "should apply char widget"); + assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:first strong').text(), 'Custom Name : ', "label should be a 'Custom Name'"); + assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:first .o_field_char').text(), 'event 4', "value should be a 'event 4'"); + + assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_form_uri', "should apply m20 widget"); + assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last strong').text(), 'user : ', "label should be a 'user'"); + assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_form_uri').text(), 'partner 1', "value should be a 'partner 1'"); + + await testUtils.dom.click($('.o_cw_popover .o_cw_popover_close')); + assert.containsNone(calendar, '.o_cw_popover', "should close a popover"); + + calendar.destroy(); + }); + + QUnit.test('render popover with modifiers', async function (assert) { + assert.expect(3); + + this.data.event.fields.priority = {string: "Priority", type: "selection", selection: [['0', 'Normal'], ['1', 'Important']],}; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week">'+ + '<field name="priority" widget="priority" readonly="1"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + await testUtils.dom.click($('.fc-event:contains(event 4)')); + + assert.containsOnce(calendar, '.o_cw_popover', "should open a popover clicking on event"); + assert.containsOnce(calendar, '.o_cw_popover .o_priority span.o_priority_star', "priority field should not be editable"); + + await testUtils.dom.click($('.o_cw_popover .o_cw_popover_close')); + assert.containsNone(calendar, '.o_cw_popover', "should close a popover"); + + calendar.destroy(); + }); + + QUnit.test('attributes hide_date and hide_time', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'hide_date="true" '+ + 'hide_time="true" '+ + 'mode="month">'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + await testUtils.dom.click($('.fc-event:contains(event 4)')); + assert.containsNone(calendar, '.o_cw_popover .list-group-item', "popover should not contain date/time"); + + calendar.destroy(); + }); + + QUnit.test('create event with timezone in week mode with formViewDialog European locale', async function (assert) { + assert.expect(8); + + this.data.event.records = []; + this.data.event.onchanges = { + allday: function (obj) { + if (obj.allday) { + obj.start_date = obj.start && obj.start.split(' ')[0] || obj.start_date; + obj.stop_date = obj.stop && obj.stop.split(' ')[0] || obj.stop_date || obj.start_date; + } else { + obj.start = obj.start_date && (obj.start_date + ' 00:00:00') || obj.start; + obj.stop = obj.stop_date && (obj.stop_date + ' 00:00:00') || obj.stop || obj.start; + } + } + }; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + translateParameters: { // Avoid issues due to localization formats + time_format: "%H:%M:%S", + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.deepEqual(args.kwargs.context, { + "default_name": "new event", + "default_start": "2016-12-13 06:00:00", + "default_stop": "2016-12-13 08:00:00", + "default_allday": null + }, + "should send the context to create events"); + } + if (args.method === "write") { + assert.deepEqual(args.args[1], expectedEvent, + "should move the event"); + } + return this._super(route, args); + }, + }, {positionalClicks: true}); + + var top = calendar.$('.fc-axis:contains(8:00)').offset().top + 5; + var left = calendar.$('.fc-day:eq(2)').offset().left + 5; + + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup"); + await testUtils.nextTick(); + await testUtils.fields.editInput($('.modal input:first'), 'new event'); + await testUtils.dom.click($('.modal button.btn:contains(Edit)')); + + assert.strictEqual($('.o_field_widget[name="start"] input').val(), + "12/13/2016 08:00:00", "should display the datetime"); + + await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input')); + await testUtils.nextTick(); + assert.strictEqual($('input[name="start_date"]').val(), + "12/13/2016", "should display the date"); + + await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input')); + + assert.strictEqual($('.o_field_widget[name="start"] input').val(), + "12/13/2016 02:00:00", "should display the datetime from the date with the timezone"); + + // use datepicker to enter a date: 12/13/2016 08:00:00 + testUtils.dom.openDatepicker($('.o_field_widget[name="start"].o_datepicker')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(08)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]')); + + // use datepicker to enter a date: 12/13/2016 10:00:00 + testUtils.dom.openDatepicker($('.o_field_widget[name="stop"].o_datepicker')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(10)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]')); + + await testUtils.dom.click($('.modal-lg button.btn:contains(Save)')); + var $newevent = calendar.$('.fc-event:contains(new event)'); + + assert.strictEqual($newevent.find('.o_event_title').text(), "new event", + "should display the new event with title"); + + assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record, + { + display_name: "new event", + start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}), + stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}), + allday: false, + name: "new event", + id: 1 + }, + "the new record should have the utc datetime (formViewDialog)"); + + var pos = calendar.$('.fc-content').offset(); + left = pos.left + 5; + top = pos.top + 5; + + // Mode this event to another day + var expectedEvent = { + "allday": false, + "start": "2016-12-12 06:00:00", + "stop": "2016-12-12 08:00:00" + }; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + left = calendar.$('.fc-day:eq(1)').offset().left + 15; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup"); + await testUtils.nextTick(); + + // Move to "All day" + expectedEvent = { + "allday": true, + "start": "2016-12-12 00:00:00", + "stop": "2016-12-12 00:00:00" + }; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + top = calendar.$('.fc-day:eq(1)').offset().top + 15; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup"); + await testUtils.nextTick(); + + calendar.destroy(); + }); + + QUnit.test('create event with timezone in week mode American locale', async function (assert) { + assert.expect(5); + + this.data.event.records = []; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week">'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="allday"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + translateParameters: { // Avoid issues due to localization formats + time_format: "%I:%M:%S", + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.deepEqual(args.kwargs.context, { + "default_start": "2016-12-13 06:00:00", + "default_stop": "2016-12-13 08:00:00", + "default_allday": null + }, + "should send the context to create events"); + } + return this._super(route, args); + }, + }, {positionalClicks: true}); + + var top = calendar.$('.fc-axis:contains(8am)').offset().top + 5; + var left = calendar.$('.fc-day:eq(2)').offset().left + 5; + + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + + testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove"); + + assert.strictEqual(calendar.$('.fc-content .fc-time').text(), "8:00 - 10:00", + "should display the time in the calendar sticker"); + + testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup"); + await testUtils.nextTick(); + testUtils.fields.editInput($('.modal input:first'), 'new event'); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + var $newevent = calendar.$('.fc-event:contains(new event)'); + + assert.strictEqual($newevent.find('.o_event_title').text(), "new event", + "should display the new event with title"); + + assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record, + { + display_name: "new event", + start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}), + stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}), + allday: false, + name: "new event", + id: 1 + }, + "the new record should have the utc datetime (quickCreate)"); + + // delete record + + await testUtils.dom.click($newevent); + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_delete')); + await testUtils.dom.click($('.modal button.btn-primary:contains(Ok)')); + assert.containsNone(calendar, '.fc-content', "should delete the record"); + + calendar.destroy(); + }); + + QUnit.test('fetch event when being in timezone', async function (assert) { + assert.expect(3); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week" >'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="allday"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 660; + }, + }, + + mockRPC: async function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, [ + ["start", "<=", "2016-12-17 12:59:59"], // in UTC. which is 2016-12-17 23:59:59 in TZ Sydney 11 hours later + ["stop", ">=", "2016-12-10 13:00:00"] // in UTC. which is 2016-12-11 00:00:00 in TZ Sydney 11 hours later + ], 'The domain should contain the right range'); + } + return this._super(route, args); + }, + }); + + assert.strictEqual(calendar.$('.fc-day-header:first').text(), 'Sun 11', + 'The calendar start date should be 2016-12-11'); + assert.strictEqual(calendar.$('.fc-day-header:last()').text(), 'Sat 17', + 'The calendar start date should be 2016-12-17'); + + calendar.destroy(); + }); + + QUnit.test('create event with timezone in week mode with formViewDialog American locale', async function (assert) { + assert.expect(8); + + this.data.event.records = []; + this.data.event.onchanges = { + allday: function (obj) { + if (obj.allday) { + obj.start_date = obj.start && obj.start.split(' ')[0] || obj.start_date; + obj.stop_date = obj.stop && obj.stop.split(' ')[0] || obj.stop_date || obj.start_date; + } else { + obj.start = obj.start_date && (obj.start_date + ' 00:00:00') || obj.start; + obj.stop = obj.stop_date && (obj.stop_date + ' 00:00:00') || obj.stop || obj.start; + } + } + }; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + translateParameters: { // Avoid issues due to localization formats + time_format: "%I:%M:%S", + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.deepEqual(args.kwargs.context, { + "default_name": "new event", + "default_start": "2016-12-13 06:00:00", + "default_stop": "2016-12-13 08:00:00", + "default_allday": null + }, + "should send the context to create events"); + } + if (args.method === "write") { + assert.deepEqual(args.args[1], expectedEvent, + "should move the event"); + } + return this._super(route, args); + }, + }, {positionalClicks: true}); + + var top = calendar.$('.fc-axis:contains(8am)').offset().top + 5; + var left = calendar.$('.fc-day:eq(2)').offset().left + 5; + + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup"); + await testUtils.nextTick(); + testUtils.fields.editInput($('.modal input:first'), 'new event'); + await testUtils.dom.click($('.modal button.btn:contains(Edit)')); + + assert.strictEqual($('.o_field_widget[name="start"] input').val(), "12/13/2016 08:00:00", + "should display the datetime"); + + await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input')); + + assert.strictEqual($('.o_field_widget[name="start_date"] input').val(), "12/13/2016", + "should display the date"); + + await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input')); + + assert.strictEqual($('.o_field_widget[name="start"] input').val(), "12/13/2016 02:00:00", + "should display the datetime from the date with the timezone"); + + // use datepicker to enter a date: 12/13/2016 08:00:00 + testUtils.dom.openDatepicker($('.o_field_widget[name="start"].o_datepicker')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(08)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]')); + + // use datepicker to enter a date: 12/13/2016 10:00:00 + testUtils.dom.openDatepicker($('.o_field_widget[name="stop"].o_datepicker')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(10)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]')); + + await testUtils.dom.click($('.modal-lg button.btn:contains(Save)')); + var $newevent = calendar.$('.fc-event:contains(new event)'); + + assert.strictEqual($newevent.find('.o_event_title').text(), "new event", + "should display the new event with title"); + + assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record, + { + display_name: "new event", + start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}), + stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}), + allday: false, + name: "new event", + id: 1 + }, + "the new record should have the utc datetime (formViewDialog)"); + + var pos = calendar.$('.fc-content').offset(); + left = pos.left + 5; + top = pos.top + 5; + + // Mode this event to another day + var expectedEvent = { + "allday": false, + "start": "2016-12-12 06:00:00", + "stop": "2016-12-12 08:00:00" + }; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + left = calendar.$('.fc-day:eq(1)').offset().left + 15; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup"); + await testUtils.nextTick(); + + // Move to "All day" + expectedEvent = { + "allday": true, + "start": "2016-12-12 00:00:00", + "stop": "2016-12-12 00:00:00" + }; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + top = calendar.$('.fc-day:eq(1)').offset().top + 15; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup"); + await testUtils.nextTick(); + + calendar.destroy(); + }); + + QUnit.test('check calendar week column timeformat', async function (assert) { + assert.expect(2); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + translateParameters: { + time_format: "%I:%M:%S", + }, + }); + + assert.strictEqual(calendar.$('.fc-axis:contains(8am)').length, 1, "calendar should show according to timeformat"); + assert.strictEqual(calendar.$('.fc-axis:contains(11pm)').length, 1, + "event time format should 12 hour"); + + calendar.destroy(); + }); + + QUnit.test('create all day event in week mode', async function (assert) { + assert.expect(3); + + this.data.event.records = []; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + }, {positionalClicks: true}); + + var pos = calendar.$('.fc-bg td:eq(4)').offset(); + try { + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + pos = calendar.$('.fc-bg td:eq(5)').offset(); + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup"); + await testUtils.nextTick(); + + testUtils.fields.editInput($('.modal input:first'), 'new event'); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + var $newevent = calendar.$('.fc-event:contains(new event)'); + + assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent", + "should display the new event with time and title"); + assert.hasAttrValue($newevent.parent(), 'colspan', "2", + "should appear over two days."); + + assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record, + { + display_name: "new event", + start: fieldUtils.parse.datetime("2016-12-14 00:00:00", this.data.event.fields.start, {isUTC: true}), + stop: fieldUtils.parse.datetime("2016-12-15 00:00:00", this.data.event.fields.stop, {isUTC: true}), + allday: true, + name: "new event", + id: 1 + }, + "the new record should have the utc datetime (quickCreate)"); + + calendar.destroy(); + }); + + QUnit.test('create event with default context (no quickCreate)', async function (assert) { + assert.expect(3); + + this.data.event.records = []; + + const calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + `<calendar + class="o_calendar_test" + date_start="start" + date_stop="stop" + mode="week" + all_day="allday" + quick_add="False"/>`, + archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset() { + return 120; + }, + }, + context: { + default_name: 'New', + }, + intercepts: { + do_action(ev) { + assert.step('do_action'); + assert.deepEqual(ev.data.action.context, { + default_name: "New", + default_start: "2016-12-14 00:00:00", + default_stop: "2016-12-15 00:00:00", + default_allday: true, + }, + "should send the correct data to create events"); + }, + }, + }, { positionalClicks: true }); + + var pos = calendar.$('.fc-bg td:eq(4)').offset(); + try { + testUtils.dom.triggerPositionalMouseEvent(pos.left + 15, pos.top + 15, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + pos = calendar.$('.fc-bg td:eq(5)').offset(); + testUtils.dom.triggerPositionalMouseEvent(pos.left + 15, pos.top + 15, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(pos.left + 15, pos.top + 15, "mouseup"); + assert.verifySteps(['do_action']); + + calendar.destroy(); + }); + + QUnit.test('create all day event in week mode (no quickCreate)', async function (assert) { + assert.expect(1); + + this.data.event.records = []; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week" '+ + 'all_day="allday" '+ + 'quick_add="False"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action.context, { + default_start: "2016-12-14 00:00:00", + default_stop: "2016-12-15 00:00:00", + default_allday: true, + }, + "should send the correct data to create events"); + }, + }, + }, {positionalClicks: true}); + + var pos = calendar.$('.fc-bg td:eq(4)').offset(); + try { + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + pos = calendar.$('.fc-bg td:eq(5)').offset(); + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup"); + + calendar.destroy(); + }); + + QUnit.test('create event in month mode', async function (assert) { + assert.expect(4); + + this.data.event.records = []; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.deepEqual(args.args[0], { + "name": "new event", + "start": "2016-12-14 05:00:00", + "stop": "2016-12-15 17:00:00", + }, + "should send the correct data to create events"); + } + return this._super(route, args); + }, + }, {positionalClicks: true}); + + var pos = calendar.$('.fc-bg td:eq(17)').offset(); + try { + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + pos = calendar.$('.fc-bg td:eq(18)').offset(); + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup"); + await testUtils.nextTick(); + + testUtils.fields.editInput($('.modal input:first'), 'new event'); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + var $newevent = calendar.$('.fc-event:contains(new event)'); + + assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent", + "should display the new event with time and title"); + assert.hasAttrValue($newevent.parent(), 'colspan', "2", + "should appear over two days."); + + assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record, { + display_name: "new event", + start: fieldUtils.parse.datetime("2016-12-14 05:00:00", this.data.event.fields.start, {isUTC: true}), + stop: fieldUtils.parse.datetime("2016-12-15 17:00:00", this.data.event.fields.stop, {isUTC: true}), + name: "new event", + id: 1 + }, "the new record should have the utc datetime (quickCreate)"); + + calendar.destroy(); + }); + + QUnit.test('use mini calendar', async function (assert) { + assert.expect(12); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + }); + + assert.containsOnce(calendar, '.fc-timeGridWeek-view', "should be in week mode"); + assert.containsN(calendar, '.fc-event', 9, "should display 9 events on the week (4 event + 5 days event)"); + await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(19)')); + // Clicking on a day in another week should switch to the other week view + assert.containsOnce(calendar, '.fc-timeGridWeek-view', "should be in week mode"); + assert.containsN(calendar, '.fc-event', 4, "should display 4 events on the week (1 event + 3 days event)"); + // Clicking on a day in the same week should switch to that particular day view + await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)')); + assert.containsOnce(calendar, '.fc-timeGridDay-view', "should be in day mode"); + assert.containsN(calendar, '.fc-event', 2, "should display 2 events on the day"); + // Clicking on the same day should toggle between day, month and week views + await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)')); + assert.containsOnce(calendar, '.fc-dayGridMonth-view', "should be in month mode"); + assert.containsN(calendar, '.fc-event', 7, "should display 7 events on the month (event 5 is on multiple weeks and generates to .fc-event)"); + await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)')); + assert.containsOnce(calendar, '.fc-timeGridWeek-view', "should be in week mode"); + assert.containsN(calendar, '.fc-event', 4, "should display 4 events on the week (1 event + 3 days event)"); + await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)')); + assert.containsOnce(calendar, '.fc-timeGridDay-view', "should be in day mode"); + assert.containsN(calendar, '.fc-event', 2, "should display 2 events on the day"); + + calendar.destroy(); + }); + + QUnit.test('rendering, with many2many', async function (assert) { + assert.expect(5); + + this.data.event.fields.partner_ids.type = 'many2many'; + this.data.event.records[0].partner_ids = [1,2,3,4,5]; + this.data.partner.records.push({id: 5, display_name: "partner 5", image: 'EEE'}); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday"> '+ + '<field name="partner_ids" widget="many2many_tags_avatar" avatar_field="image" write_model="filter_partner" write_field="partner_id"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.containsN(calendar, '.o_calendar_filter_items .o_cw_filter_avatar', 3, + "should have 3 avatars in the side bar"); + + // Event 1 + await testUtils.dom.click(calendar.$('.fc-event:first')); + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + assert.strictEqual(calendar.$('.o_cw_popover').find('img').length, 1, "should have 1 avatar"); + + // Event 2 + await testUtils.dom.click(calendar.$('.fc-event:eq(1)')); + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + assert.strictEqual(calendar.$('.o_cw_popover').find('img').length, 5, "should have 5 avatar"); + + calendar.destroy(); + }); + + QUnit.test('open form view', async function (assert) { + assert.expect(3); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "get_formview_id") { + return Promise.resolve('A view'); + } + return this._super(route, args); + }, + }); + + // click on an existing event to open the form view + + testUtils.mock.intercept(calendar, 'do_action', function (event) { + assert.deepEqual(event.data.action, + { + type: "ir.actions.act_window", + res_id: 4, + res_model: "event", + views: [['A view', "form"]], + target: "current", + context: {} + }, + "should open the form view"); + }); + await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content')); + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit')); + + // create a new event and edit it + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + testUtils.fields.editInput($('.modal-body input:first'), 'coucou'); + + testUtils.mock.intercept(calendar, 'do_action', function (event) { + assert.deepEqual(event.data.action, + { + type: "ir.actions.act_window", + res_model: "event", + views: [[false, "form"]], + target: "current", + context: { + "default_name": "coucou", + "default_start": "2016-12-27 00:00:00", + "default_stop": "2016-12-27 00:00:00", + "default_allday": true + } + }, + "should open the form view with the context default values"); + }); + + testUtils.dom.click($('.modal button.btn:contains(Edit)')); + + calendar.destroy(); + + assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body"); + }); + + QUnit.test('create and edit event in month mode (all_day: false)', async function (assert) { + assert.expect(2); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return -240; + }, + }, + }); + + // create a new event and edit it + var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + await testUtils.fields.editInput($('.modal-body input:first'), 'coucou'); + + testUtils.mock.intercept(calendar, 'do_action', function (event) { + assert.deepEqual(event.data.action, + { + type: "ir.actions.act_window", + res_model: "event", + views: [[false, "form"]], + target: "current", + context: { + "default_name": "coucou", + "default_start": "2016-12-27 11:00:00", // 7:00 + 4h + "default_stop": "2016-12-27 23:00:00", // 19:00 + 4h + } + }, + "should open the form view with the context default values"); + }); + + await testUtils.dom.click($('.modal button.btn:contains(Edit)')); + + calendar.destroy(); + assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body"); + }); + + QUnit.test('show start time of single day event for month mode', async function (assert) { + assert.expect(4); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" ' + + 'string="Events" ' + + 'date_start="start" ' + + 'date_stop="stop" ' + + 'all_day="allday" ' + + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return -240; + }, + }, + }); + + assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "06:55", + "should have a correct time 06:55 AM in month mode"); + assert.strictEqual(calendar.$('.fc-event:contains(event 4) .fc-content .fc-time').text(), "", + "should not display a time for all day event"); + assert.strictEqual(calendar.$('.fc-event:contains(event 5) .fc-content .fc-time').text(), "", + "should not display a time for multiple days event"); + // switch to week mode + await testUtils.dom.click(calendar.$('.o_calendar_button_week')); + assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "", + "should not show time in week mode as week mode already have time on y-axis"); + + calendar.destroy(); + }); + + QUnit.test('start time should not shown for date type field', async function (assert) { + assert.expect(1); + + this.data.event.fields.start.type = "date"; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" ' + + 'string="Events" ' + + 'date_start="start" ' + + 'date_stop="stop" ' + + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return -240; + }, + }, + }); + + assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "", + "should not show time for date type field"); + + calendar.destroy(); + }); + + QUnit.test('start time should not shown in month mode if hide_time is true', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" ' + + 'string="Events" ' + + 'date_start="start" ' + + 'date_stop="stop" ' + + 'hide_time="True" ' + + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return -240; + }, + }, + }); + + assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "", + "should not show time for hide_time attribute"); + + calendar.destroy(); + }); + + QUnit.test('readonly date_start field', async function (assert) { + assert.expect(4); + + this.data.event.fields.start.readonly = true; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "get_formview_id") { + return Promise.resolve(false); + } + return this._super(route, args); + }, + }); + + assert.containsNone(calendar, '.fc-resizer', "should not have resize button"); + + // click on an existing event to open the form view + + testUtils.mock.intercept(calendar, 'do_action', function (event) { + assert.deepEqual(event.data.action, + { + type: "ir.actions.act_window", + res_id: 4, + res_model: "event", + views: [[false, "form"]], + target: "current", + context: {} + }, + "should open the form view"); + }); + await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content')); + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit')); + + // create a new event and edit it + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + await testUtils.fields.editInput($('.modal-body input:first'), 'coucou'); + + testUtils.mock.intercept(calendar, 'do_action', function (event) { + assert.deepEqual(event.data.action, + { + type: "ir.actions.act_window", + res_model: "event", + views: [[false, "form"]], + target: "current", + context: { + "default_name": "coucou", + "default_start": "2016-12-27 00:00:00", + "default_stop": "2016-12-27 00:00:00", + "default_allday": true + } + }, + "should open the form view with the context default values"); + }); + + await testUtils.dom.click($('.modal button.btn:contains(Edit)')); + + calendar.destroy(); + + assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body"); + }); + + QUnit.test('"all" filter', async function (assert) { + assert.expect(6); + + var interval = [ + ["start", "<=", "2016-12-17 23:59:59"], + ["stop", ">=", "2016-12-11 00:00:00"], + ]; + + var domains = [ + interval.concat([["partner_ids", "in", [2,1]]]), + interval.concat([["partner_ids", "in", [1]]]), + interval, + ]; + + var i = 0; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '</calendar>', + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, domains[i]); + i++; + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(calendar, '.fc-event', 9, + "should display 9 events on the week"); + + // Select the events only associated with partner 2 + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-id=2] input')); + assert.containsN(calendar, '.fc-event', 4, + "should display 4 events on the week"); + + // Click on the 'all' filter to reload all events + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=all] input')); + assert.containsN(calendar, '.fc-event', 9, + "should display 9 events on the week"); + + calendar.destroy(); + }); + + QUnit.test('Add filters and specific color', async function (assert) { + assert.expect(5); + + this.data.event.records.push( + {id: 8, user_id: 4, partner_id: 1, name: "event 8", start: "2016-12-11 09:00:00", stop: "2016-12-11 10:00:00", allday: false, partner_ids: [1,2,3], event_type_id: 3, color: 4}, + {id: 9, user_id: 4, partner_id: 1, name: "event 9", start: "2016-12-11 19:00:00", stop: "2016-12-11 20:00:00", allday: false, partner_ids: [1,2,3], event_type_id: 1, color: 1}, + ); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'color="color">'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '<field name="event_type_id" filters="1" color="color"/>'+ + '</calendar>', + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.containsN(calendar, '.o_calendar_filter', 2, "should display 2 filters"); + + var $typeFilter = calendar.$('.o_calendar_filter:has(h5:contains(Event_Type))'); + assert.ok($typeFilter.length, "should display 'Event Type' filter"); + assert.containsN($typeFilter, '.o_calendar_filter_item', 3, "should display 3 filter items for 'Event Type'"); + + assert.containsOnce($typeFilter, '.o_calendar_filter_item[data-value=3].o_cw_filter_color_4', "Filter for event type 3 must have the color 4"); + + assert.containsOnce(calendar, '.fc-event[data-event-id=8].o_calendar_color_4', "Event of event type 3 must have the color 4"); + + calendar.destroy(); + }); + + QUnit.test('create event with filters', async function (assert) { + assert.expect(7); + + this.data.event.fields.user_id.default = 5; + this.data.event.fields.partner_id.default = 3; + this.data.user.records.push({id: 5, display_name: "user 5", partner_id: 3}); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '<field name="partner_id" filters="1" invisible="1"/>'+ + '</calendar>', + viewOptions: { + initialDate: initialDate, + }, + }, {positionalClicks: true}); + + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input')); + + assert.containsN(calendar, '.o_calendar_filter_item', 5, "should display 5 filter items"); + assert.containsN(calendar, '.fc-event', 3, "should display 3 events"); + + // quick create a record + var left = calendar.$('.fc-bg td:eq(4)').offset().left+15; + var top = calendar.$('.fc-slats tr:eq(12) td:first').offset().top+15; + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mouseup"); + await testUtils.nextTick(); + + await testUtils.fields.editInput($('.modal-body input:first'), 'coucou'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Create)')); + + assert.containsN(calendar, '.o_calendar_filter_item', 6, "should add the missing filter (active)"); + assert.containsN(calendar, '.fc-event', 4, "should display the created item"); + await testUtils.nextTick(); + + // change default value for quick create an hide record + this.data.event.fields.user_id.default = 4; + this.data.event.fields.partner_id.default = 4; + + // quick create and other record + left = calendar.$('.fc-bg td:eq(3)').offset().left+15; + top = calendar.$('.fc-slats tr:eq(12) td:first').offset().top+15; + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mouseup"); + await testUtils.nextTick(); + + testUtils.fields.editInput($('.modal-body input:first'), 'coucou 2'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Create)')); + + assert.containsN(calendar, '.o_calendar_filter_item', 6, "should have the same filters"); + assert.containsN(calendar, '.fc-event', 4, "should not display the created item"); + + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input')); + + assert.containsN(calendar, '.fc-event', 11, "should display all records"); + + calendar.destroy(); + }); + + QUnit.test('create event with filters (no quickCreate)', async function (assert) { + assert.expect(4); + + this.data.event.fields.user_id.default = 5; + this.data.event.fields.partner_id.default = 3; + this.data.user.records.push({ + id: 5, + display_name: "user 5", + partner_id: 3 + }); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '<field name="partner_id" filters="1" invisible="1"/>'+ + '</calendar>', + archs: { + "event,false,form": + '<form>'+ + '<group>'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="stop"/>'+ + '<field name="user_id"/>'+ + '<field name="partner_id" invisible="1"/>'+ + '</group>'+ + '</form>', + }, + viewOptions: { + initialDate: initialDate, + }, + }, {positionalClicks: true}); + + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input')); + + assert.containsN(calendar, '.o_calendar_filter_item', 5, "should display 5 filter items"); + assert.containsN(calendar, '.fc-event', 3, "should display 3 events"); + await testUtils.nextTick(); + + // quick create a record + var left = calendar.$('.fc-bg td:eq(4)').offset().left+15; + var top = calendar.$('.fc-slats tr:eq(12) td:first').offset().top+15; + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mousemove"); + testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mouseup"); + await testUtils.nextTick(); + + await testUtils.fields.editInput($('.modal-body input:first'), 'coucou'); + + await testUtils.dom.click($('.modal-footer button.btn:contains(Edit)')); + await testUtils.dom.click($('.modal-footer button.btn:contains(Save)')); + + assert.containsN(calendar, '.o_calendar_filter_item', 6, "should add the missing filter (active)"); + assert.containsN(calendar, '.fc-event', 4, "should display the created item"); + + calendar.destroy(); + }); + + QUnit.test('Update event with filters', async function (assert) { + assert.expect(6); + + var records = this.data.user.records; + records.push({ + id: 5, + display_name: "user 5", + partner_id: 3 + }); + + this.data.event.onchanges = { + user_id: function (obj) { + obj.partner_id = _.findWhere(records, {id:obj.user_id}).partner_id; + } + }; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '<field name="partner_id" filters="1" invisible="1"/>'+ + '</calendar>', + archs: { + "event,false,form": + '<form>'+ + '<group>'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="stop"/>'+ + '<field name="user_id"/>'+ + '<field name="partner_ids" widget="many2many_tags"/>'+ + '</group>'+ + '</form>', + }, + viewOptions: { + initialDate: initialDate, + }, + }); + + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input')); + + assert.containsN(calendar, '.o_calendar_filter_item', 5, "should display 5 filter items"); + assert.containsN(calendar, '.fc-event', 3, "should display 3 events"); + + await testUtils.dom.click(calendar.$('.fc-event:contains(event 2) .fc-content')); + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit')); + assert.strictEqual($('.modal .modal-title').text(), 'Open: event 2', "dialog should have a valid title"); + await testUtils.dom.click($('.modal .o_field_widget[name="user_id"] input')); + await testUtils.dom.click($('.ui-menu-item a:contains(user 5)').trigger('mouseenter')); + await testUtils.dom.click($('.modal button.btn:contains(Save)')); + + assert.containsN(calendar, '.o_calendar_filter_item', 6, "should add the missing filter (active)"); + assert.containsN(calendar, '.fc-event', 3, "should display the updated item"); + + calendar.destroy(); + }); + + QUnit.test('change pager with filters', async function (assert) { + assert.expect(3); + + this.data.user.records.push({ + id: 5, + display_name: "user 5", + partner_id: 3 + }); + this.data.event.records.push({ + id: 8, + user_id: 5, + partner_id: 3, + name: "event 8", + start: "2016-12-06 04:00:00", + stop: "2016-12-06 08:00:00", + allday: false, + partner_ids: [1,2,3], + type: 1 + }, { + id: 9, + user_id: session.uid, + partner_id: 1, + name: "event 9", + start: "2016-12-07 04:00:00", + stop: "2016-12-07 08:00:00", + allday: false, + partner_ids: [1,2,3], + type: 1 + },{ + id: 10, + user_id: 4, + partner_id: 4, + name: "event 10", + start: "2016-12-08 04:00:00", + stop: "2016-12-08 08:00:00", + allday: false, + partner_ids: [1,2,3], + type: 1 + }); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '<field name="partner_id" filters="1" invisible="1"/>'+ + '</calendar>', + viewOptions: { + initialDate: initialDate, + }, + }); + + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input')); + await testUtils.dom.click($('.o_calendar_button_prev')); + + assert.containsN(calendar, '.o_calendar_filter_item', 6, "should display 6 filter items"); + assert.containsN(calendar, '.fc-event', 2, "should display 2 events"); + assert.strictEqual(calendar.$('.fc-event .o_event_title').text().replace(/\s/g, ''), "event8event9", + "should display 2 events"); + + calendar.destroy(); + }); + + QUnit.test('ensure events are still shown if filters give an empty domain', async function (assert) { + assert.expect(2); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar mode="week" date_start="start">' + + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>' + + '</calendar>', + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.containsN(calendar, '.fc-event', 5, + "should display 5 events"); + await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=all] input[type=checkbox]')); + assert.containsN(calendar, '.fc-event', 5, + "should display 5 events"); + calendar.destroy(); + }); + + QUnit.test('events starting at midnight', async function (assert) { + assert.expect(3); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar mode="week" date_start="start"/>', + viewOptions: { + initialDate: initialDate, + }, + translateParameters: { // Avoid issues due to localization formats + time_format: "%H:%M:%S", + }, + }, {positionalClicks: true}); + + // Reset the scroll to 0 as we want to create an event from midnight + assert.ok(calendar.$('.fc-scroller')[0].scrollTop > 0, + "should scroll to 6:00 by default (this is true at least for resolutions up to 1900x1600)"); + calendar.$('.fc-scroller')[0].scrollTop = 0; + + // Click on Tuesday 12am + var top = calendar.$('.fc-axis:contains(0:00)').offset().top + 5; + var left = calendar.$('.fc-day:eq(2)').offset().left + 5; + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup"); + await testUtils.nextTick(); + } catch (e) { + calendar.destroy(); + throw new Error('The test failed to simulate a click on the screen.' + + 'Your screen is probably too small or your dev tools are open.'); + } + assert.ok($('.modal-dialog.modal-sm').length, + "should open the quick create dialog"); + + // Creating the event + testUtils.fields.editInput($('.modal-body input:first'), 'new event in quick create'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Create)')); + assert.strictEqual(calendar.$('.fc-event:contains(new event in quick create)').length, 1, + "should display the new record after quick create dialog"); + + calendar.destroy(); + }); + + QUnit.test('set event as all day when field is date', async function (assert) { + assert.expect(2); + + this.data.event.records[0].start_date = "2016-12-14"; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start_date" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return -480; + } + }, + }); + assert.containsOnce(calendar, '.fc-day-grid .fc-event-container', + "should be one event in the all day row"); + assert.strictEqual(moment(calendar.model.data.data[0].r_start).date(), 14, + "the date should be 14"); + calendar.destroy(); + }); + + QUnit.test('set event as all day when field is date (without all_day mapping)', async function (assert) { + assert.expect(1); + + this.data.event.records[0].start_date = "2016-12-14"; + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: `<calendar date_start="start_date" mode="week"></calendar>`, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + assert.containsOnce(calendar, '.fc-day-grid .fc-event-container', + "should be one event in the all day row"); + calendar.destroy(); + }); + + QUnit.test('quickcreate avoid double event creation', async function (assert) { + assert.expect(1); + var createCount = 0; + var prom = testUtils.makeTestPromise(); + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + var result = this._super(route, args); + if (args.method === "create") { + createCount++; + return prom.then(_.constant(result)); + } + return result; + }, + }); + + // create a new event + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + var $input = $('.modal input:first'); + await testUtils.fields.editInput($input, 'new event in quick create'); + // Simulate ENTER pressed on Create button (after a TAB) + $input.trigger($.Event('keyup', { + which: $.ui.keyCode.ENTER, + keyCode: $.ui.keyCode.ENTER, + })); + await testUtils.nextTick(); + await testUtils.dom.click($('.modal-footer button:first')); + prom.resolve(); + await testUtils.nextTick(); + assert.strictEqual(createCount, 1, + "should create only one event"); + + calendar.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: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start_date" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="partner_id">'+ + '<filter name="user_id" avatar_field="image"/>'+ + '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }; + + var calendar = await createCalendarView(params); + assert.ok(instanceNumber > 0); + + calendar.destroy(); + assert.strictEqual(instanceNumber, 0); + + testUtils.mock.unpatch(mixins.ParentedMixin); + }); + + QUnit.test('create an event (async dialog) [REQUIRE FOCUS]', async function (assert) { + assert.expect(3); + + var prom = testUtils.makeTestPromise(); + testUtils.mock.patch(Dialog, { + open: function () { + var _super = this._super.bind(this); + prom.then(_super); + return this; + }, + }); + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + // create an event + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + assert.strictEqual($('.modal').length, 0, + "should not have opened the dialog yet"); + + prom.resolve(); + await testUtils.nextTick(); + + assert.strictEqual($('.modal').length, 1, + "should have opened the dialog"); + assert.strictEqual($('.modal input')[0], document.activeElement, + "should focus the input in the dialog"); + + calendar.destroy(); + testUtils.mock.unpatch(Dialog); + }); + + QUnit.test('calendar is configured to have no groupBy menu', async function (assert) { + assert.expect(1); + + var archs = { + 'event,1,calendar': '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday"/>', + 'event,false,search': '<search></search>', + }; + + var actions = [{ + id: 1, + name: 'some action', + res_model: 'event', + type: 'ir.actions.act_window', + views: [[1, 'calendar']] + }]; + + var actionManager = await createActionManager({ + actions: actions, + archs: archs, + data: this.data, + }); + + await actionManager.doAction(1); + assert.containsNone(actionManager.$('.o_control_panel .o_search_options span.fa.fa-bars'), + "the control panel has no groupBy menu"); + actionManager.destroy(); + }); + + QUnit.test('timezone does not affect current day', async function (assert) { + assert.expect(2); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start_date"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return -2400; // 40 hours timezone + }, + }, + + }); + + var $sidebar = calendar.$('.o_calendar_sidebar'); + + assert.strictEqual($sidebar.find('.ui-datepicker-current-day').text(), "12", "should highlight the target day"); + + // go to previous day + await testUtils.dom.click($sidebar.find('.ui-datepicker-current-day').prev()); + + assert.strictEqual($sidebar.find('.ui-datepicker-current-day').text(), "11", "should highlight the selected day"); + + calendar.destroy(); + }); + + QUnit.test('timezone does not affect drag and drop', async function (assert) { + assert.expect(10); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start" mode="month">'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "write") { + assert.deepEqual(args.args[0], [6], "event 6 is moved") + assert.deepEqual(args.args[1].start, "2016-11-29 08:00:00", + "event moved to 27th nov 16h00 +40 hours timezone") + } + return this._super(route, args); + }, + session: { + getTZOffset: function () { + return -2400; // 40 hours timezone + }, + }, + }); + + assert.strictEqual(calendar.$('.fc-event:eq(0)').text().replace(/\s/g, ''), "08:00event1"); + await testUtils.dom.click(calendar.$('.fc-event:eq(0)')); + assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "12/09/2016 08:00:00"); + + assert.strictEqual(calendar.$('.fc-event:eq(5)').text().replace(/\s/g, ''), "16:00event6"); + await testUtils.dom.click(calendar.$('.fc-event:eq(5)')); + assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "12/16/2016 16:00:00"); + + // Move event 6 as on first day of month view (27th november 2016) + await testUtils.dragAndDrop( + calendar.$('.fc-event').eq(5), + calendar.$('.fc-day-top').first() + ); + await testUtils.nextTick(); + + assert.strictEqual(calendar.$('.fc-event:eq(0)').text().replace(/\s/g, ''), "16:00event6"); + await testUtils.dom.click(calendar.$('.fc-event:eq(0)')); + assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "11/27/2016 16:00:00"); + + assert.strictEqual(calendar.$('.fc-event:eq(1)').text().replace(/\s/g, ''), "08:00event1"); + await testUtils.dom.click(calendar.$('.fc-event:eq(1)')); + assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "12/09/2016 08:00:00"); + + calendar.destroy(); + }); + + QUnit.test('timzeone does not affect calendar with date field', async function (assert) { + assert.expect(11); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start_date" mode="month">'+ + '<field name="name"/>'+ + '<field name="start_date"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.strictEqual(args.args[0].start_date, "2016-12-20 00:00:00"); + } + if (args.method === "write") { + assert.step(args.args[1].start_date); + } + return this._super(route, args); + }, + session: { + getTZOffset: function () { + return 120; // 2 hours timezone + }, + }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + await testUtils.triggerMouseEvent($cell, "mousedown"); + await testUtils.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "An event"); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + await testUtils.nextTick(); + + await testUtils.dom.click(calendar.$('.fc-event:contains(An event)')); + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_field_date').text(), '12/20/2016', "should have correct start date"); + + // Move event to another day (on 27 november) + await testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').first() + ); + await testUtils.nextTick(); + assert.verifySteps(["2016-11-27 00:00:00"]); + await testUtils.dom.click(calendar.$('.fc-event:contains(An event)')); + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_field_date').text(), '11/27/2016', "should have correct start date"); + + // Move event to last day (on 7 january) + await testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').last() + ); + await testUtils.nextTick(); + assert.verifySteps(["2017-01-07 00:00:00"]); + await testUtils.dom.click(calendar.$('.fc-event:contains(An event)')); + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_field_date').text(), '01/07/2017', "should have correct start date"); + calendar.destroy(); + }); + + QUnit.test("drag and drop on month mode", async function (assert) { + assert.expect(3); + + const calendar = await createCalendarView({ + arch: + `<calendar date_start="start" date_stop="stop" mode="month" event_open_popup="true" quick_add="False"> + <field name="name"/> + <field name="partner_id"/> + </calendar>`, + archs: archs, + data: this.data, + model: 'event', + View: CalendarView, + viewOptions: { initialDate: initialDate }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "An event"); + await testUtils.dom.click($('.modal button.btn-primary')); + await testUtils.nextTick(); + + await testUtils.dragAndDrop( + calendar.$('.fc-event:contains("event 1")'), + calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day-top:eq(1)'), + { disableDrop: true }, + ); + assert.hasClass(calendar.$('.o_calendar_widget > [data-event-id="1"]'), 'dayGridMonth', + "should have dayGridMonth class"); + + // Move event to another day (on 19 december) + await testUtils.dragAndDrop( + calendar.$('.fc-event:contains("An event")'), + calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day-top:eq(1)') + ); + await testUtils.nextTick(); + await testUtils.dom.click(calendar.$('.fc-event:contains("An event")')); + + assert.containsOnce(calendar, '.popover:contains("07:00")', + "start hour shouldn't have been changed"); + assert.containsOnce(calendar, '.popover:contains("19:00")', + "end hour shouldn't have been changed"); + + calendar.destroy(); + }); + + QUnit.test("drag and drop on month mode with all_day mapping", async function (assert) { + // Same test as before but in calendarEventToRecord (calendar_model.js) there is + // different condition branching with all_day mapping or not + assert.expect(2); + + const calendar = await createCalendarView({ + arch: + `<calendar date_start="start" date_stop="stop" mode="month" event_open_popup="true" quick_add="False" all_day="allday"> + <field name="name"/> + <field name="partner_id"/> + </calendar>`, + archs: archs, + data: this.data, + model: 'event', + View: CalendarView, + viewOptions: { initialDate: initialDate }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "An event"); + await testUtils.dom.click($('.o_field_widget[name="allday"] input')); + await testUtils.nextTick(); + + // use datepicker to enter a date: 12/20/2016 07:00:00 + testUtils.dom.openDatepicker($('.o_field_widget[name="start"].o_datepicker')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(07)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]')); + + // use datepicker to enter a date: 12/20/2016 19:00:00 + testUtils.dom.openDatepicker($('.o_field_widget[name="stop"].o_datepicker')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(19)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]')); + + await testUtils.dom.click($('.modal button.btn-primary')); + await testUtils.nextTick(); + + // Move event to another day (on 19 december) + await testUtils.dom.dragAndDrop( + calendar.$('.fc-event:contains("An event")'), + calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day-top:eq(1)') + ); + await testUtils.nextTick(); + await testUtils.dom.click(calendar.$('.fc-event:contains("An event")')); + + assert.containsOnce(calendar, '.popover:contains("07:00")', + "start hour shouldn't have been changed"); + assert.containsOnce(calendar, '.popover:contains("19:00")', + "end hour shouldn't have been changed"); + + calendar.destroy(); + }); + + QUnit.test('drag and drop on month mode with date_start and date_delay', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start" date_delay="delay" mode="month">'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="delay"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "write") { + // delay should not be written at drag and drop + assert.equal(args.args[1].delay, undefined) + } + return this._super(route, args); + }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + await testUtils.triggerMouseEvent($cell, "mousedown"); + await testUtils.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "An event"); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + await testUtils.nextTick(); + + // Move event to another day (on 27 november) + await testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').first() + ); + await testUtils.nextTick(); + + calendar.destroy(); + }); + + QUnit.test('form_view_id attribute works (for creating events)', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month" '+ + 'form_view_id="42"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + // we simulate here the case where a create call with just + // the field name fails. This is a normal flow, the server + // reject the create rpc (quick create), then the web client + // fall back to a form view. This happens typically when a + // model has required fields + return Promise.reject('None shall pass!'); + } + return this._super(route, args); + }, + intercepts: { + do_action: function (event) { + assert.strictEqual(event.data.action.views[0][0], 42, + "should do a do_action with view id 42"); + }, + }, + }); + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + await testUtils.dom.triggerMouseEvent($cell, "mousedown"); + await testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "It's just a fleshwound"); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + await testUtils.nextTick(); // wait a little before to finish the test + calendar.destroy(); + }); + + QUnit.test('form_view_id attribute works with popup (for creating events)', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month" '+ + 'event_open_popup="true" ' + + 'quick_add="false" ' + + 'form_view_id="1">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "load_views") { + assert.strictEqual(args.kwargs.views[0][0], 1, + "should load view with id 1"); + } + return this._super(route, args); + }, + }); + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + await testUtils.dom.triggerMouseEvent($cell, "mousedown"); + await testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + calendar.destroy(); + }); + + QUnit.test('calendar fallback to form view id in action if necessary', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + action: {views: [{viewID: 1, type: 'kanban'}, {viewID: 43, type: 'form'}]} + }, + mockRPC: function (route, args) { + if (args.method === "create") { + // we simulate here the case where a create call with just + // the field name fails. This is a normal flow, the server + // reject the create rpc (quick create), then the web client + // fall back to a form view. This happens typically when a + // model has required fields + return Promise.reject('None shall pass!'); + } + return this._super(route, args); + }, + intercepts: { + do_action: function (event) { + assert.strictEqual(event.data.action.views[0][0], 43, + "should do a do_action with view id 43"); + }, + }, + }); + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "It's just a fleshwound"); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + calendar.destroy(); + }); + + QUnit.test('fullcalendar initializes with right locale', async function (assert) { + assert.expect(1); + + var initialLocale = moment.locale(); + // This will set the locale to zz + moment.defineLocale('zz', { + longDateFormat: { + L: 'DD/MM/YYYY' + }, + weekdaysShort: ["zz1.", "zz2.", "zz3.", "zz4.", "zz5.", "zz6.", "zz7."], + }); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + action: {views: [{viewID: 1, type: 'kanban'}, {viewID: 43, type: 'form'}]} + }, + + }); + + assert.strictEqual(calendar.$('.fc-day-header:first').text(), "zz1. 11", + 'The day should be in the given locale specific format'); + + moment.locale(initialLocale); + + calendar.destroy(); + }); + + QUnit.test('default week start (US) month mode', async function (assert) { + // if not given any option, default week start is on Sunday + assert.expect(8); + + // 2019-09-12 08:00:00 + var initDate = new Date(2019, 8, 12, 8, 0, 0); + initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initDate, + }, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, [ + ["start","<=","2019-10-12 23:59:59"], + ["stop",">=","2019-09-01 00:00:00"] + ], + 'The domain to search events in should be correct'); + } + return this._super.apply(this, arguments); + } + }); + + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Sunday", + "The first day of the week should be Sunday"); + assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Saturday", + "The last day of the week should be Saturday"); + + var $firstDay = calendar.$('.fc-day-top').first(); + + assert.strictEqual($firstDay.find('.fc-week-number').text(), "36", + "The number of the week should be correct"); + assert.strictEqual($firstDay.find('.fc-day-number').text(), "1", + "The first day of the week should be 2019-09-01"); + assert.strictEqual($firstDay.data('date'), "2019-09-01", + "The first day of the week should be 2019-09-01"); + + var $lastDay = calendar.$('.fc-day-top').last(); + assert.strictEqual($lastDay.text(), "12", + "The last day of the week should be 2019-10-12"); + assert.strictEqual($lastDay.data('date'), "2019-10-12", + "The last day of the week should be 2019-10-12"); + + calendar.destroy(); + }); + + QUnit.test('European week start month mode', async function (assert) { + assert.expect(8); + + // 2019-09-12 08:00:00 + var initDate = new Date(2019, 8, 15, 8, 0, 0); + initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initDate, + }, + translateParameters: { + week_start: 1, + }, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, [ + ["start","<=","2019-10-06 23:59:59"], + ["stop",">=","2019-08-26 00:00:00"] + ], + 'The domain to search events in should be correct'); + } + return this._super.apply(this, arguments); + } + }); + + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Monday", + "The first day of the week should be Monday"); + assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sunday", + "The last day of the week should be Sunday"); + + var $firstDay = calendar.$('.fc-day-top').first(); + assert.strictEqual($firstDay.find('.fc-week-number').text(), "35", + "The number of the week should be correct"); + assert.strictEqual($firstDay.find('.fc-day-number').text(), "26", + "The first day of the week should be 2019-09-01"); + assert.strictEqual($firstDay.data('date'), "2019-08-26", + "The first day of the week should be 2019-08-26"); + + var $lastDay = calendar.$('.fc-day-top').last(); + assert.strictEqual($lastDay.text(), "6", + "The last day of the week should be 2019-10-06"); + assert.strictEqual($lastDay.data('date'), "2019-10-06", + "The last day of the week should be 2019-10-06"); + + calendar.destroy(); + }); + + QUnit.test('Monday week start week mode', async function (assert) { + assert.expect(3); + + // 2019-09-12 08:00:00 + var initDate = new Date(2019, 8, 15, 8, 0, 0); + initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initDate, + }, + translateParameters: { + week_start: 1, + }, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, [ + ["start","<=","2019-09-15 23:59:59"], + ["stop",">=","2019-09-09 00:00:00"] + ], + 'The domain to search events in should be correct'); + } + return this._super.apply(this, arguments); + } + }); + + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Mon 9", + "The first day of the week should be Monday the 9th"); + assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sun 15", + "The last day of the week should be Sunday the 15th"); + + calendar.destroy(); + }); + + QUnit.test('Saturday week start week mode', async function (assert) { + assert.expect(3); + + // 2019-09-12 08:00:00 + var initDate = new Date(2019, 8, 12, 8, 0, 0); + initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initDate, + }, + translateParameters: { + week_start: 6, + }, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'event') { + assert.deepEqual(args.kwargs.domain, [ + ["start","<=","2019-09-13 23:59:59"], + ["stop",">=","2019-09-07 00:00:00"] + ], + 'The domain to search events in should be correct'); + } + return this._super.apply(this, arguments); + } + }); + + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Sat 7", + "The first day of the week should be Saturday the 7th"); + assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Fri 13", + "The last day of the week should be Friday the 13th"); + + calendar.destroy(); + }); + + QUnit.test('edit record and attempt to create a record with "create" attribute set to false', async function (assert) { + assert.expect(8); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" '+ + 'event_open_popup="true" '+ + 'create="false" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month"/>', + archs: archs, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], {name: 'event 4 modified'}, "should update the record"); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + initialDate: initialDate, + }, + }); + + // editing existing events should still be possible + // click on an existing event to open the formViewDialog + + await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content')); + + assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event"); + assert.ok(calendar.$('.o_cw_popover .o_cw_popover_edit').length, "popover should have an edit button"); + assert.ok(calendar.$('.o_cw_popover .o_cw_popover_delete').length, "popover should have a delete button"); + assert.ok(calendar.$('.o_cw_popover .o_cw_popover_close').length, "popover should have a close button"); + + await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit')); + + assert.ok($('.modal-body').length, "should open the form view in dialog when click on edit"); + + await testUtils.fields.editInput($('.modal-body input:first'), 'event 4 modified'); + await testUtils.dom.click($('.modal-footer button.btn:contains(Save)')); + + assert.notOk($('.modal-body').length, "save button should close the modal"); + + // creating an event should not be possible + // attempt to create a new event with create set to false + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + assert.notOk($('.modal-sm').length, "shouldn't open a quick create dialog for creating a new event with create attribute set to false"); + + calendar.destroy(); + }); + + + QUnit.test('attempt to create record with "create" and "quick_add" attributes set to false', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" '+ + 'create="false" '+ + 'event_open_popup="true" '+ + 'quick_add="false" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month"/>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + // attempt to create a new event with create set to false + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(5) .fc-day:eq(2)'); + + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + + assert.strictEqual($('.modal').length, 0, "shouldn't open a form view for creating a new event with create attribute set to false"); + + calendar.destroy(); + }); + + QUnit.test('attempt to create multiples events and the same day and check the ordering on month view', async function (assert) { + assert.expect(3); + /* + This test aims to verify that the order of the event in month view is coherent with their start date. + */ + var initDate = new Date(2020, 2, 12, 8, 0, 0); //12 of March + this.data.event.records = [ + {id: 1, name: "Second event", start: "2020-03-12 05:00:00", stop: "2020-03-12 07:00:00", allday: false}, + {id: 2, name: "First event", start: "2020-03-12 02:00:00", stop: "2020-03-12 03:00:00", allday: false}, + {id: 3, name: "Third event", start: "2020-03-12 08:00:00", stop: "2020-03-12 09:00:00", allday: false}, + ]; + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: `<calendar date_start="start" date_stop="stop" all_day="allday" mode="month" />`, + archs: archs, + viewOptions: { + initialDate: initDate, + }, + }); + assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length, "should display in the calendar"); // OK + // Testing the order of the events: by start date + assert.strictEqual(calendar.$('.o_event_title').length, 3, "3 events should be available"); // OK + assert.strictEqual(calendar.$('.o_event_title').first().text(), 'First event', "First event should be on top"); + calendar.destroy(); + }); + + QUnit.test("drag and drop 24h event on week mode", async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: ` + <calendar + event_open_popup="true" + quick_add="False" + date_start="start" + date_stop="stop" + all_day="allday" + mode="week" + /> + `, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }, {positionalClicks: true}); + + var top = calendar.$('.fc-axis:contains(8:00)').offset().top + 5; + var left = calendar.$('.fc-day:eq(2)').offset().left + 5; + + try { + testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown"); + } catch (e) { + calendar.destroy(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + + top = calendar.$('.fc-axis:contains(8:00)').offset().top - 5; + var leftNextDay = calendar.$('.fc-day:eq(3)').offset().left + 5; + testUtils.dom.triggerPositionalMouseEvent(leftNextDay, top, "mousemove"); + await testUtils.dom.triggerPositionalMouseEvent(leftNextDay, top, "mouseup"); + await testUtils.nextTick(); + assert.equal($('.o_field_boolean.o_field_widget[name=allday] input').is(':checked'), false, + "The event must not have the all_day active"); + await testUtils.dom.click($('.modal button.btn:contains(Discard)')); + + calendar.destroy(); + }); + + QUnit.test('correctly display year view', async function (assert) { + assert.expect(27); + + const calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: ` + <calendar + create="false" + event_open_popup="true" + date_start="start" + date_stop="stop" + all_day="allday" + mode="year" + attendee="partner_ids" + color="partner_id" + > + <field name="partner_ids" write_model="filter_partner" write_field="partner_id"/> + <field name="partner_id" filters="1" invisible="1"/> + </calendar>`, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }, {positionalClicks: true}); + + // Check view + assert.containsN(calendar, '.fc-month', 12); + assert.strictEqual( + calendar.el.querySelector('.fc-month:first-child .fc-header-toolbar').textContent, + 'Jan 2016' + ); + assert.containsN(calendar.el, '.fc-bgevent', 7, + 'There should be 6 events displayed but there is 1 split on 2 weeks'); + + async function clickDate(date) { + const el = calendar.el.querySelector(`.fc-day-top[data-date="${date}"]`); + el.scrollIntoView(); // scroll to it as the calendar could be too small + + testUtils.dom.triggerMouseEvent(el, "mousedown"); + testUtils.dom.triggerMouseEvent(el, "mouseup"); + + await testUtils.nextTick(); + } + + assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-11-17"]') + .classList.contains('fc-has-event')); + await clickDate('2016-11-17'); + assert.containsNone(calendar, '.o_cw_popover'); + + assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-11-16"]') + .classList.contains('fc-has-event')); + await clickDate('2016-11-16'); + assert.containsOnce(calendar, '.o_cw_popover'); + let popoverText = calendar.el.querySelector('.o_cw_popover') + .textContent.replace(/\s{2,}/g, ' ').trim(); + assert.strictEqual(popoverText, 'November 14-16, 2016 event 7'); + await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close')); + assert.containsNone(calendar, '.o_cw_popover'); + + assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-11-14"]') + .classList.contains('fc-has-event')); + await clickDate('2016-11-14'); + assert.containsOnce(calendar, '.o_cw_popover'); + popoverText = calendar.el.querySelector('.o_cw_popover') + .textContent.replace(/\s{2,}/g, ' ').trim(); + assert.strictEqual(popoverText, 'November 14-16, 2016 event 7'); + await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close')); + assert.containsNone(calendar, '.o_cw_popover'); + + assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-11-13"]') + .classList.contains('fc-has-event')); + await clickDate('2016-11-13'); + assert.containsNone(calendar, '.o_cw_popover'); + + assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-12-10"]') + .classList.contains('fc-has-event')); + await clickDate('2016-12-10'); + assert.containsNone(calendar, '.o_cw_popover'); + + assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-12-12"]') + .classList.contains('fc-has-event')); + await clickDate('2016-12-12'); + assert.containsOnce(calendar, '.o_cw_popover'); + popoverText = calendar.el.querySelector('.o_cw_popover') + .textContent.replace(/\s{2,}/g, ' ').trim(); + assert.strictEqual(popoverText, 'December 12, 2016 event 2 event 3'); + await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close')); + assert.containsNone(calendar, '.o_cw_popover'); + + assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-12-14"]') + .classList.contains('fc-has-event')); + await clickDate('2016-12-14'); + assert.containsOnce(calendar, '.o_cw_popover'); + popoverText = calendar.el.querySelector('.o_cw_popover') + .textContent.replace(/\s{2,}/g, ' ').trim(); + assert.strictEqual(popoverText, + 'December 14, 2016 event 4 December 13-20, 2016 event 5'); + await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close')); + assert.containsNone(calendar, '.o_cw_popover'); + + assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-12-21"]') + .classList.contains('fc-has-event')); + await clickDate('2016-12-21'); + assert.containsNone(calendar, '.o_cw_popover'); + + calendar.destroy(); + }); + + QUnit.test('toggle filters in year view', async function (assert) { + assert.expect(42); + + const calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: ` + <calendar + event_open_popup="true" + date_start="start" + date_stop="stop" + all_day="allday" + mode="year" + attendee="partner_ids" + color="partner_id" + > + <field name="partner_ids" write_model="filter_partner" write_field="partner_id"/> + <field name="partner_id" filters="1" invisible="1"/> + '</calendar>`, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + function checkEvents(countMap) { + for (const [id, count] of Object.entries(countMap)) { + assert.containsN(calendar, `.fc-bgevent[data-event-id="${id}"]`, count); + } + } + + checkEvents({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 2, 7: 1, }); + await testUtils.dom.click(calendar.el.querySelector( + '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="2"] label')); + checkEvents({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 0, 7: 0, }); + await testUtils.dom.click(calendar.el.querySelector( + '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="1"] label')); + checkEvents({ 1: 0, 2: 0, 3: 1, 4: 0, 5: 0, 7: 0, }); + await testUtils.dom.click(calendar.el.querySelector( + '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="4"] label')); + checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, }); + await testUtils.dom.click(calendar.el.querySelector( + '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="1"] label')); + checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, }); + await testUtils.dom.click(calendar.el.querySelector( + '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="2"] label')); + checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, }); + await testUtils.dom.click(calendar.el.querySelector( + '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="4"] label')); + checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 2, 7: 0, }); + + calendar.destroy(); + }); + + QUnit.test('allowed scales', async function (assert) { + assert.expect(8); + + let calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + `<calendar + date_start="start" + date_stop="stop" + all_day="allday"/>`, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_day'); + assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_week'); + assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_month'); + assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_year'); + + calendar.destroy(); + + calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + `<calendar + date_start="start" + date_stop="stop" + all_day="allday" + scales="day,week"/>`, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_day'); + assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_week'); + assert.containsNone(calendar, '.o_calendar_scale_buttons .o_calendar_button_month'); + assert.containsNone(calendar, '.o_calendar_scale_buttons .o_calendar_button_year'); + + calendar.destroy(); + }); + + QUnit.test('click outside the popup should close it', async function (assert) { + assert.expect(4); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + `<calendar + create="false" + event_open_popup="true" + quick_add="false" + date_start="start" + date_stop="stop" + all_day="allday" + mode="month"/>`, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.containsNone(calendar, '.o_cw_popover'); + + await testUtils.dom.click(calendar.el.querySelector('.fc-event .fc-content')); + assert.containsOnce(calendar, '.o_cw_popover', + 'open popup when click on event'); + + await testUtils.dom.click(calendar.el.querySelector('.o_cw_body')); + assert.containsOnce(calendar, '.o_cw_popover', + 'keep popup openned when click inside popup'); + + await testUtils.dom.click(calendar.el.querySelector('.o_content')); + assert.containsNone(calendar, '.o_cw_popover', + 'close popup when click outside popup'); + + calendar.destroy(); + }); + + QUnit.test("fields are added in the right order in popover", async function (assert) { + assert.expect(3); + + const def = testUtils.makeTestPromise(); + const DeferredWidget = AbstractField.extend({ + async start() { + await this._super(...arguments); + await def; + } + }); + fieldRegistry.add("deferred_widget", DeferredWidget); + + const calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + `<calendar + date_start="start" + date_stop="stop" + all_day="allday" + mode="month" + > + <field name="user_id" widget="deferred_widget" /> + <field name="name" /> + </calendar>`, + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + await testUtils.dom.click(calendar.$(`[data-event-id="4"]`)); + assert.containsNone(calendar, ".o_cw_popover"); + + def.resolve(); + await testUtils.nextTick(); + assert.containsOnce(calendar, ".o_cw_popover"); + + assert.strictEqual( + calendar.$(".o_cw_popover .o_cw_popover_fields_secondary").text(), + "user : name : event 4" + ); + + calendar.destroy(); + delete fieldRegistry.map.deferred_widget; + }); + +}); + +}); diff --git a/addons/web/static/tests/views/form_benchmarks.js b/addons/web/static/tests/views/form_benchmarks.js new file mode 100644 index 00000000..88913bcb --- /dev/null +++ b/addons/web/static/tests/views/form_benchmarks.js @@ -0,0 +1,108 @@ +odoo.define('web.form_benchmarks', function (require) { + "use strict"; + + const FormView = require('web.FormView'); + const testUtils = require('web.test_utils'); + + const { createView } = testUtils; + + QUnit.module('Form View', { + beforeEach: function () { + this.data = { + foo: { + fields: { + foo: {string: "Foo", type: "char"}, + many2many: { string: "bar", type: "many2many", relation: 'bar'}, + }, + records: [ + { id: 1, foo: "bar", many2many: []}, + ], + onchanges: {} + }, + bar: { + fields: { + char: {string: "char", type: "char"}, + many2many: { string: "pokemon", type: "many2many", relation: 'pokemon'}, + }, + records: [], + onchanges: {} + }, + pokemon: { + fields: { + name: {string: "Name", type: "char"}, + }, + records: [], + onchanges: {} + }, + }; + this.arch = null; + this.run = function (assert, viewParams, cb) { + const data = this.data; + const arch = this.arch; + return new Promise(resolve => { + new Benchmark.Suite({}) + .add('form', { + defer: true, + fn: async (deferred) => { + const form = await createView(Object.assign({ + View: FormView, + model: 'foo', + data, + arch, + }, viewParams)); + if (cb) { + await cb(form); + } + form.destroy(); + deferred.resolve(); + }, + }) + .on('cycle', event => { + assert.ok(true, String(event.target)); + }) + .on('complete', resolve) + .run({ async: true }); + }); + }; + } + }, function () { + QUnit.test('x2many with 250 rows, 2 fields (with many2many_tags, and modifiers), onchanges, and edition', function (assert) { + assert.expect(1); + + this.data.foo.onchanges.many2many = function (obj) { + obj.many2many = [5].concat(obj.many2many); + }; + for (let i = 2; i < 500; i++) { + this.data.bar.records.push({ + id: i, + char: "automated data", + }); + this.data.foo.records[0].many2many.push(i); + } + this.arch = ` + <form> + <field name="many2many"> + <tree editable="top" limit="250"> + <field name="char"/> + <field name="many2many" widget="many2many_tags" attrs="{'readonly': [('char', '==', 'toto')]}"/> + </tree> + </field> + </form>`; + return this.run(assert, { res_id: 1 }, async form => { + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_data_cell:first')); + await testUtils.fields.editInput(form.$('input:first'), "tralala"); + }); + }); + + QUnit.test('form view with 100 fields, half of them being invisible', function (assert) { + assert.expect(1); + + this.arch = ` + <form> + ${[...Array(100)].map((_, i) => '<field name="foo"' + (i % 2 ? ' invisible="1"' : '') + '/>').join('')} + </form>`; + return this.run(assert); + }); + }); +}); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js new file mode 100644 index 00000000..094d913f --- /dev/null +++ b/addons/web/static/tests/views/form_tests.js @@ -0,0 +1,9907 @@ +odoo.define('web.form_tests', function (require) { +"use strict"; + +const AbstractField = require("web.AbstractField"); +var AbstractStorageService = require('web.AbstractStorageService'); +var BasicModel = require('web.BasicModel'); +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var fieldRegistry = require('web.field_registry'); +const fieldRegistryOwl = require('web.field_registry_owl'); +const FormRenderer = require('web.FormRenderer'); +var FormView = require('web.FormView'); +var mixins = require('web.mixins'); +var NotificationService = require('web.NotificationService'); +var pyUtils = require('web.py_utils'); +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 createView = testUtils.createView; +var createActionManager = testUtils.createActionManager; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + display_name: { string: "Displayed name", type: "char" }, + foo: {string: "Foo", type: "char", default: "My little Foo Value"}, + bar: {string: "Bar", type: "boolean"}, + int_field: {string: "int_field", type: "integer", sortable: true}, + qux: {string: "Qux", type: "float", digits: [16,1] }, + p: {string: "one2many field", type: "one2many", relation: 'partner'}, + trululu: {string: "Trululu", type: "many2one", relation: 'partner'}, + timmy: { string: "pokemon", type: "many2many", relation: 'partner_type'}, + product_id: {string: "Product", type: "many2one", relation: 'product'}, + priority: { + string: "Priority", + type: "selection", + selection: [[1, "Low"], [2, "Medium"], [3, "High"]], + default: 1, + }, + state: {string: "State", type: "selection", selection: [["ab", "AB"], ["cd", "CD"], ["ef", "EF"]]}, + date: {string: "Some Date", type: "date"}, + datetime: {string: "Datetime Field", type: 'datetime'}, + product_ids: {string: "one2many product", type: "one2many", relation: "product"}, + reference: {string: "Reference Field", type: 'reference', selection: [["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]}, + }, + records: [{ + id: 1, + display_name: "first record", + bar: true, + foo: "yop", + int_field: 10, + qux: 0.44, + p: [], + timmy: [], + trululu: 4, + state: "ab", + date: "2017-01-25", + datetime: "2016-12-12 10:55:05", + }, { + id: 2, + display_name: "second record", + bar: true, + foo: "blip", + int_field: 9, + qux: 13, + p: [], + timmy: [], + trululu: 1, + state: "cd", + }, { + id: 4, + display_name: "aaa", + state: "ef", + }, { + id: 5, + display_name: "aaa", + foo:'', + bar:false, + state: "ef", + }], + onchanges: {}, + }, + product: { + fields: { + display_name: {string: "Product Name", type: "char"}, + name: {string: "Product Name", type: "char"}, + partner_type_id: {string: "Partner type", type: "many2one", relation: "partner_type"}, + }, + records: [{ + id: 37, + display_name: "xphone", + }, { + id: 41, + display_name: "xpad", + }] + }, + partner_type: { + fields: { + name: {string: "Partner Type", type: "char"}, + color: {string: "Color index", type: "integer"}, + }, + records: [ + {id: 12, display_name: "gold", color: 2}, + {id: 14, display_name: "silver", color: 5}, + ] + }, + "ir.translation": { + fields: { + lang_code: {type: "char"}, + value: {type: "char"}, + res_id: {type: "integer"} + }, + records: [{ + id: 99, + res_id: 12, + value: '', + lang_code: 'en_US' + }] + }, + user: { + fields: { + name: {string: "Name", type: "char"}, + partner_ids: {string: "one2many partners field", type: "one2many", relation: 'partner', relation_field: 'user_id'}, + }, + records: [{ + id: 17, + name: "Aline", + partner_ids: [1], + }, { + id: 19, + name: "Christine", + }] + }, + "res.company": { + fields: { + name: { string: "Name", type: "char" }, + }, + }, + }; + this.actions = [{ + id: 1, + name: 'Partners Action 1', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'kanban'], [false, 'form']], + }]; + }, +}, function () { + + QUnit.module('FormView'); + + QUnit.test('simple form rendering', async function (assert) { + assert.expect(12); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<div class="test" style="opacity: 0.5;">some html<span>aa</span></div>' + + '<sheet>' + + '<group>' + + '<group style="background-color: red">' + + '<field name="foo" style="color: blue"/>' + + '<field name="bar"/>' + + '<field name="int_field" string="f3_description"/>' + + '<field name="qux"/>' + + '</group>' + + '<group>' + + '<div class="hello"></div>' + + '</group>' + + '</group>' + + '<notebook>' + + '<page string="Partner Yo">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + + assert.containsOnce(form, 'div.test'); + assert.strictEqual(form.$('div.test').css('opacity'), '0.5', + "should keep the inline style on html elements"); + assert.containsOnce(form, 'label:contains(Foo)'); + assert.containsOnce(form, 'span:contains(blip)'); + assert.hasAttrValue(form.$('.o_group .o_group:first'), 'style', 'background-color: red', + "should apply style attribute on groups"); + assert.hasAttrValue(form.$('.o_field_widget[name=foo]'), 'style', 'color: blue', + "should apply style attribute on fields"); + assert.containsNone(form, 'label:contains(something_id)'); + assert.containsOnce(form, 'label:contains(f3_description)'); + assert.containsOnce(form, 'div.o_field_one2many table'); + assert.containsOnce(form, 'tbody td:not(.o_list_record_selector) .custom-checkbox input:checked'); + assert.containsOnce(form, '.o_control_panel .breadcrumb:contains(second record)'); + assert.containsNone(form, 'label.o_form_label_empty:contains(timmy)'); + + form.destroy(); + }); + + QUnit.test('duplicate fields rendered properly', async function (assert) { + assert.expect(6); + this.data.partner.records.push({ + id: 6, + bar: true, + foo: "blip", + int_field: 9, + }); + var form = await createView({ + View: FormView, + viewOptions: { mode: 'edit' }, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<group>' + + '<field name="foo" attrs="{\'invisible\': [(\'bar\',\'=\',True)]}"/>' + + '<field name="foo" attrs="{\'invisible\': [(\'bar\',\'=\',False)]}"/>' + + '<field name="foo"/>' + + '<field name="int_field" attrs="{\'readonly\': [(\'bar\',\'=\',False)]}"/>' + + '<field name="int_field" attrs="{\'readonly\': [(\'bar\',\'=\',True)]}"/>' + + '<field name="bar"/>' + + '</group>' + + '</group>' + + '</form>', + res_id: 6, + }); + + assert.hasClass(form.$('div.o_group input[name="foo"]:eq(0)'), 'o_invisible_modifier', 'first foo widget should be invisible'); + assert.containsOnce(form, 'div.o_group input[name="foo"]:eq(1):not(.o_invisible_modifier)', "second foo widget should be visible"); + assert.containsOnce(form, 'div.o_group input[name="foo"]:eq(2):not(.o_invisible_modifier)', "third foo widget should be visible"); + await testUtils.fields.editInput(form.$('div.o_group input[name="foo"]:eq(2)'), "hello"); + assert.strictEqual(form.$('div.o_group input[name="foo"]:eq(1)').val(), "hello", "second foo widget should be 'hello'"); + assert.containsOnce(form, 'div.o_group input[name="int_field"]:eq(0):not(.o_readonly_modifier)', "first int_field widget should not be readonly"); + assert.hasClass(form.$('div.o_group span[name="int_field"]:eq(0)'),'o_readonly_modifier', "second int_field widget should be readonly"); + form.destroy(); + }); + + QUnit.test('duplicate fields rendered properly (one2many)', async function (assert) { + assert.expect(7); + this.data.partner.records.push({ + id: 6, + p: [1], + }); + var form = await createView({ + View: FormView, + viewOptions: { mode: 'edit' }, + model: 'partner', + data: this.data, + arch: '<form>' + + '<notebook>' + + '<page>' + + '<field name="p">' + + '<tree editable="True">' + + '<field name="foo"/>' + + '</tree>' + + '<form/>' + + '</field>' + + '</page>' + + '<page>' + + '<field name="p" readonly="True">' + + '<tree editable="True">' + + '<field name="foo"/>' + + '</tree>' + + '<form/>' + + '</field>' + + '</page>' + + '</notebook>' + + '</form>', + res_id: 6, + }); + assert.containsOnce(form, 'div.o_field_one2many:eq(0):not(.o_readonly_modifier)', "first one2many widget should not be readonly"); + assert.hasClass(form.$('div.o_field_one2many:eq(1)'),'o_readonly_modifier', "second one2many widget should be readonly"); + await testUtils.dom.click(form.$('div.tab-content table.o_list_table:eq(0) tr.o_data_row td.o_data_cell:eq(0)')); + assert.strictEqual(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]').val(), "yop", + "first line in one2many of first tab contains yop"); + assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(), + "yop", "first line in one2many of second tab contains yop"); + await testUtils.fields.editInput(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]'), "hello"); + assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(), "hello", + "first line in one2many of second tab contains hello"); + await testUtils.dom.click(form.$('div.tab-content table.o_list_table:eq(0) a:contains(Add a line)')); + assert.strictEqual(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]').val(), "My little Foo Value", + "second line in one2many of first tab contains 'My little Foo Value'"); + assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row:eq(1) td.o_data_cell:eq(0)').text(), + "My little Foo Value", "first line in one2many of second tab contains hello"); + form.destroy(); + }); + + QUnit.test('attributes are transferred on async widgets', async function (assert) { + assert.expect(1); + var done = assert.async(); + + var def = testUtils.makeTestPromise(); + + var FieldChar = fieldRegistry.get('char'); + fieldRegistry.add('asyncwidget', FieldChar.extend({ + willStart: function () { + return def; + }, + })); + + createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="foo" style="color: blue" widget="asyncwidget"/>' + + '</group>' + + '</form>', + res_id: 2, + }).then(function (form) { + assert.hasAttrValue(form.$('.o_field_widget[name=foo]'), 'style', 'color: blue', + "should apply style attribute on fields"); + form.destroy(); + delete fieldRegistry.map.asyncwidget; + done(); + }); + def.resolve(); + await testUtils.nextTick(); + }); + + QUnit.test('placeholder attribute on input', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<input placeholder="chimay"/>' + + '</form>', + res_id: 2, + }); + + assert.containsOnce(form, 'input[placeholder="chimay"]'); + form.destroy(); + }); + + QUnit.test('decoration works on widgets', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="display_name" decoration-danger="int_field < 5"/>' + + '<field name="foo" decoration-danger="int_field > 5"/>' + + '</form>', + res_id: 2, + }); + assert.doesNotHaveClass(form.$('span[name="display_name"]'), 'text-danger'); + assert.hasClass(form.$('span[name="foo"]'), 'text-danger'); + form.destroy(); + }); + + QUnit.test('decoration on widgets are reevaluated if necessary', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="display_name" decoration-danger="int_field < 5"/>' + + '</form>', + res_id: 2, + viewOptions: {mode: 'edit'}, + }); + assert.doesNotHaveClass(form.$('input[name="display_name"]'), 'text-danger'); + await testUtils.fields.editInput(form.$('input[name=int_field]'), 3); + assert.hasClass(form.$('input[name="display_name"]'), 'text-danger'); + form.destroy(); + }); + + QUnit.test('decoration on widgets works on same widget', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field" decoration-danger="int_field < 5"/>' + + '</form>', + res_id: 2, + viewOptions: {mode: 'edit'}, + }); + assert.doesNotHaveClass(form.$('input[name="int_field"]'), 'text-danger'); + await testUtils.fields.editInput(form.$('input[name=int_field]'), 3); + assert.hasClass(form.$('input[name="int_field"]'), 'text-danger'); + form.destroy(); + }); + + QUnit.test('only necessary fields are fetched with correct context', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + // NOTE: actually, the current web client always request the __last_update + // field, not sure why. Maybe this test should be modified. + assert.deepEqual(args.args[1], ["foo", "display_name"], + "should only fetch requested fields"); + assert.deepEqual(args.kwargs.context, {bin_size: true}, + "bin_size should always be in the context"); + return this._super(route, args); + } + }); + form.destroy(); + }); + + QUnit.test('group rendering', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, 'table.o_inner_group'); + + form.destroy(); + }); + + QUnit.test('group containing both a field and a group', async function (assert) { + // The purpose of this test is to check that classnames defined in a + // field widget and those added by the form renderer are correctly + // combined. For instance, the renderer adds className 'o_group_col_x' + // on outer group's children (an outer group being a group that contains + // at least a group). + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<group>' + + '<field name="int_field"/>' + + '</group>' + + '</group>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, '.o_group .o_field_widget[name=foo]'); + assert.containsOnce(form, '.o_group .o_inner_group .o_field_widget[name=int_field]'); + + assert.hasClass(form.$('.o_field_widget[name=foo]'), 'o_field_char'); + assert.hasClass(form.$('.o_field_widget[name=foo]'), 'o_group_col_6'); + + form.destroy(); + }); + + QUnit.test('Form and subview with _view_ref contexts', async function (assert) { + assert.expect(2); + + this.data.product.fields.partner_type_ids = {string: "one2many field", type: "one2many", relation: "partner_type"}, + this.data.product.records = [{id: 1, name: 'Tromblon', partner_type_ids: [12,14]}]; + this.data.partner.records[0].product_id = 1; + + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'product,false,form': '<form>'+ + '<field name="name"/>'+ + '<field name="partner_type_ids" context="{\'tree_view_ref\': \'some_other_tree_view\'}"/>' + + '</form>', + + 'partner_type,false,list': '<tree>'+ + '<field name="color"/>'+ + '</tree>', + 'product,false,search': '<search></search>', + }, + mockRPC: function (route, args) { + if (args.method === 'load_views') { + var context = args.kwargs.context; + if (args.model === 'product') { + assert.deepEqual(context, {tree_view_ref: 'some_tree_view'}, + 'The correct _view_ref should have been sent to the server, first time'); + } + if (args.model === 'partner_type') { + assert.deepEqual(context, { + base_model_name: 'product', + tree_view_ref: 'some_other_tree_view', + }, 'The correct _view_ref should have been sent to the server for the subview'); + } + } + return this._super.apply(this, arguments); + }, + }); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="product_id" context="{\'tree_view_ref\': \'some_tree_view\'}"/>' + + '</form>', + res_id: 1, + + mockRPC: function(route, args) { + if (args.method === 'get_formview_action') { + return Promise.resolve({ + res_id: 1, + type: 'ir.actions.act_window', + target: 'current', + res_model: args.model, + context: args.kwargs.context, + 'view_mode': 'form', + 'views': [[false, 'form']], + }); + } + return this._super(route, args); + }, + + interceptsPropagate: { + do_action: function (ev) { + actionManager.doAction(ev.data.action); + }, + }, + }); + await testUtils.dom.click(form.$('.o_field_widget[name="product_id"]')); + form.destroy(); + actionManager.destroy(); + }); + + QUnit.test('invisible fields are properly hidden', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" invisible="1"/>' + + '<field name="bar"/>' + + '</group>' + + '<field name="qux" invisible="1"/>' + + // x2many field without inline view: as it is always invisible, the view + // should not be fetched. we don't specify any view in this test, so if it + // ever tries to fetch it, it will crash, indicating that this is wrong. + '<field name="p" invisible="True"/>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.containsNone(form, 'label:contains(Foo)'); + assert.containsNone(form, '.o_field_widget[name=foo]'); + assert.containsNone(form, '.o_field_widget[name=qux]'); + assert.containsNone(form, '.o_field_widget[name=p]'); + + form.destroy(); + }); + + QUnit.test('invisible elements are properly hidden', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header invisible="1">' + + '<button name="myaction" string="coucou"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<group string="invgroup" invisible="1">' + + '<field name="foo"/>' + + '</group>' + + '</group>' + + '<notebook>' + + '<page string="visible"/>' + + '<page string="invisible" invisible="1"/>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + assert.containsOnce(form, '.o_form_statusbar.o_invisible_modifier button:contains(coucou)'); + assert.containsOnce(form, '.o_notebook li.o_invisible_modifier a:contains(invisible)'); + assert.containsOnce(form, 'table.o_inner_group.o_invisible_modifier td:contains(invgroup)'); + form.destroy(); + }); + + QUnit.test('invisible attrs on fields are re-evaluated on field change', async function (assert) { + assert.expect(3); + + // we set the value bar to simulate a falsy boolean value. + this.data.partner.records[0].bar = false; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="product_id"/>' + + '<field name="timmy" invisible="1"/>' + + '<field name="foo" class="foo_field" attrs=\'{"invisible": [["product_id", "=", false]]}\'/>' + + '<field name="bar" class="bar_field" attrs=\'{"invisible":[("bar","=",False),("timmy","=",[])]}\'/>' + + '</group></sheet>' + + '</form>', + res_id: 1, + viewOptions: { + mode:'edit' + }, + }); + + assert.hasClass(form.$('.foo_field'), 'o_invisible_modifier'); + assert.hasClass(form.$('.bar_field'), 'o_invisible_modifier'); + + // set a value on the m2o + await testUtils.fields.many2one.searchAndClickItem('product_id'); + assert.doesNotHaveClass(form.$('.foo_field'), 'o_invisible_modifier'); + + form.destroy(); + }); + + QUnit.test('asynchronous fields can be set invisible', async function (assert) { + assert.expect(1); + var done = assert.async(); + + var def = testUtils.makeTestPromise(); + + // we choose this widget because it is a quite simple widget with a non + // empty qweb template + var PercentPieWidget = fieldRegistry.get('percentpie'); + fieldRegistry.add('asyncwidget', PercentPieWidget.extend({ + willStart: function () { + return def; + }, + })); + + createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="foo"/>' + + '<field name="int_field" invisible="1" widget="asyncwidget"/>' + + '</group></sheet>' + + '</form>', + res_id: 1, + }).then(function (form) { + assert.containsNone(form, '.o_field_widget[name="int_field"]'); + form.destroy(); + delete fieldRegistry.map.asyncwidget; + done(); + }); + def.resolve(); + }); + + QUnit.test('properly handle modifiers and attributes on notebook tags', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="product_id"/>' + + '<notebook class="new_class" attrs=\'{"invisible": [["product_id", "=", false]]}\'>' + + '<page string="Foo">' + + '<field name="foo"/>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.hasClass(form.$('.o_notebook'), 'o_invisible_modifier'); + assert.hasClass(form.$('.o_notebook'), 'new_class'); + form.destroy(); + }); + + QUnit.test('empty notebook', async function (assert) { + assert.expect(2); + + const form = await createView({ + arch: ` + <form string="Partners"> + <sheet> + <notebook/> + </sheet> + </form>`, + data: this.data, + model: 'partner', + res_id: 1, + View: FormView, + }); + + // Does not change when switching state + await testUtils.form.clickEdit(form); + + assert.containsNone(form, ':scope .o_notebook .nav'); + + // Does not change when coming back to initial state + await testUtils.form.clickSave(form); + + assert.containsNone(form, ':scope .o_notebook .nav'); + + form.destroy(); + }); + + QUnit.test('no visible page', async function (assert) { + assert.expect(4); + + const form = await createView({ + arch: ` + <form string="Partners"> + <sheet> + <notebook> + <page string="Foo" invisible="1"> + <field name="foo"/> + </page> + <page string="Bar" invisible="1"> + <field name="bar"/> + </page> + </notebook> + </sheet> + </form>`, + data: this.data, + model: 'partner', + res_id: 1, + View: FormView, + }); + + // Does not change when switching state + await testUtils.form.clickEdit(form); + + for (const nav of form.el.querySelectorAll(':scope .o_notebook .nav')) { + assert.containsNone(nav, '.nav-link.active'); + assert.containsN(nav, '.nav-item.o_invisible_modifier', 2); + } + + // Does not change when coming back to initial state + await testUtils.form.clickSave(form); + + for (const nav of form.el.querySelectorAll(':scope .o_notebook .nav')) { + assert.containsNone(nav, '.nav-link.active'); + assert.containsN(nav, '.nav-item.o_invisible_modifier', 2); + } + + form.destroy(); + }); + + QUnit.test('notebook: pages with invisible modifiers', async function (assert) { + assert.expect(10); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: `<form string="Partners"> + <sheet> + <field name="bar"/> + <notebook> + <page string="First" attrs='{"invisible": [["bar", "=", false]]}'> + <field name="foo"/> + </page> + <page string="Second" attrs='{"invisible": [["bar", "=", true]]}'> + <field name="int_field"/> + </page> + <page string="Third"> + <field name="qux"/> + </page> + </notebook> + </sheet> + </form>`, + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + + assert.containsOnce(form, ".o_notebook .nav .nav-link.active", + "There should be only one active tab" + ); + assert.isVisible(form.$(".o_notebook .nav .nav-item:first")); + assert.hasClass(form.$(".o_notebook .nav .nav-link:first"), "active"); + + assert.isNotVisible(form.$(".o_notebook .nav .nav-item:eq(1)")); + assert.doesNotHaveClass(form.$(".o_notebook .nav .nav-link:eq(1)"), "active"); + + await testUtils.dom.click(form.$(".o_field_widget[name=bar] input")); + + assert.containsOnce(form, ".o_notebook .nav .nav-link.active", + "There should be only one active tab" + ); + assert.isNotVisible(form.$(".o_notebook .nav .nav-item:first")); + assert.doesNotHaveClass(form.$(".o_notebook .nav .nav-link:first"), "active"); + + assert.isVisible(form.$(".o_notebook .nav .nav-item:eq(1)")); + assert.hasClass(form.$(".o_notebook .nav .nav-link:eq(1)"), "active"); + + form.destroy(); + }); + + QUnit.test('invisible attrs on first notebook page', async function (assert) { + assert.expect(6); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="product_id"/>' + + '<notebook>' + + '<page string="Foo" attrs=\'{"invisible": [["product_id", "!=", false]]}\'>' + + '<field name="foo"/>' + + '</page>' + + '<page string="Bar">' + + '<field name="bar"/>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + assert.hasClass(form.$('.o_notebook .nav .nav-link:first()'), 'active'); + assert.doesNotHaveClass(form.$('.o_notebook .nav .nav-item:first()'), 'o_invisible_modifier'); + + // set a value on the m2o + await testUtils.fields.many2one.searchAndClickItem('product_id'); + assert.doesNotHaveClass(form.$('.o_notebook .nav .nav-link:first()'), 'active'); + assert.hasClass(form.$('.o_notebook .nav .nav-item:first()'), 'o_invisible_modifier'); + assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active'); + assert.hasClass(form.$('.o_notebook .tab-content .tab-pane:nth(1)'), 'active'); + form.destroy(); + }); + + QUnit.test('invisible attrs on notebook page which has only one page', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="bar"/>' + + '<notebook>' + + '<page string="Foo" attrs=\'{"invisible": [["bar", "!=", false]]}\'>' + + '<field name="foo"/>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.notOk(form.$('.o_notebook .nav .nav-link:first()').hasClass('active'), + 'first tab should not be active'); + assert.ok(form.$('.o_notebook .nav .nav-item:first()').hasClass('o_invisible_modifier'), + 'first tab should be invisible'); + + // enable checkbox + await testUtils.dom.click(form.$('.o_field_boolean input')); + assert.ok(form.$('.o_notebook .nav .nav-link:first()').hasClass('active'), + 'first tab should be active'); + assert.notOk(form.$('.o_notebook .nav .nav-item:first()').hasClass('o_invisible_modifier'), + 'first tab should be visible'); + + form.destroy(); + }); + + QUnit.test('first notebook page invisible', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="product_id"/>' + + '<notebook>' + + '<page string="Foo" invisible="1">' + + '<field name="foo"/>' + + '</page>' + + '<page string="Bar">' + + '<field name="bar"/>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.notOk(form.$('.o_notebook .nav .nav-item:first()').is(':visible'), + 'first tab should be invisible'); + assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active'); + + form.destroy(); + }); + + QUnit.test('autofocus on second notebook page', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="product_id"/>' + + '<notebook>' + + '<page string="Choucroute">' + + '<field name="foo"/>' + + '</page>' + + '<page string="Cassoulet" autofocus="autofocus">' + + '<field name="bar"/>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.doesNotHaveClass(form.$('.o_notebook .nav .nav-link:first()'), 'active'); + assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active'); + + form.destroy(); + }); + + QUnit.test('invisible attrs on group are re-evaluated on field change', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="bar"/>' + + '<group attrs=\'{"invisible": [["bar", "!=", true]]}\'>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit' + }, + }); + + assert.containsOnce(form, 'div.o_group:visible'); + await testUtils.dom.click('.o_field_boolean input', form); + assert.containsOnce(form, 'div.o_group:hidden'); + form.destroy(); + }); + + QUnit.test('invisible attrs with zero value in domain and unset value in data', async function (assert) { + assert.expect(1); + + this.data.partner.fields.int_field.type = 'monetary'; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '<group attrs=\'{"invisible": [["int_field", "=", 0.0]]}\'>' + + '<div class="hello">this should be invisible</div>' + + '<field name="int_field"/>' + + '</group>' + + '</sheet>' + + '</form>', + }); + + assert.isNotVisible(form.$('div.hello')); + form.destroy(); + }); + + QUnit.test('reset local state when switching to another view', async function (assert) { + assert.expect(3); + + const actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,form': `<form> + <sheet> + <field name="product_id"/> + <notebook> + <page string="Foo"> + <field name="foo"/> + </page> + <page string="Bar"> + <field name="bar"/> + </page> + </notebook> + </sheet> + </form>`, + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,search': '<search></search>', + }, + actions: [{ + id: 1, + name: 'Partner', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list'], [false, 'form']], + }], + }); + + await actionManager.doAction(1); + + await testUtils.dom.click(actionManager.$('.o_list_button_add')); + assert.containsOnce(actionManager, '.o_form_view'); + + // click on second page tab + await testUtils.dom.click(actionManager.$('.o_notebook .nav-link:eq(1)')); + + await testUtils.dom.click('.o_control_panel .o_form_button_cancel'); + assert.containsNone(actionManager, '.o_form_view'); + + await testUtils.dom.click(actionManager.$('.o_list_button_add')); + // check notebook active page is 0th page + assert.hasClass(actionManager.$('.o_notebook .nav-link:eq(0)'), 'active'); + + actionManager.destroy(); + }); + + QUnit.test('rendering stat buttons', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<div name="button_box">' + + '<button class="oe_stat_button">' + + '<field name="int_field"/>' + + '</button>' + + '<button class="oe_stat_button" attrs=\'{"invisible": [["bar", "=", true]]}\'>' + + '<field name="bar"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.containsN(form, 'button.oe_stat_button', 2); + assert.containsOnce(form, 'button.oe_stat_button.o_invisible_modifier'); + + var count = 0; + await testUtils.mock.intercept(form, "execute_action", function () { + count++; + }); + await testUtils.dom.click('.oe_stat_button'); + assert.strictEqual(count, 1, "should have triggered a execute action"); + form.destroy(); + }); + + QUnit.test('label uses the string attribute', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<label for="bar" string="customstring"/>' + + '<div><field name="bar"/></div>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.containsOnce(form, 'label.o_form_label:contains(customstring)'); + form.destroy(); + }); + + QUnit.test('input ids for multiple occurrences of fields in form view', async function (assert) { + // A same field can occur several times in the view, but its id must be + // unique by occurrence, otherwise there is a warning in the console (in + // edit mode) as we get several inputs with the same "id" attribute, and + // several labels the same "for" attribute. + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <group> + <field name="foo"/> + <label for="qux"/> + <div><field name="qux"/></div> + </group> + <group> + <field name="foo"/> + <label for="qux2"/> + <div><field name="qux" id="qux2"/></div> + </group> + </form>`, + }); + + const fieldIdAttrs = [...form.$('.o_field_widget')].map(n => n.getAttribute('id')); + const labelForAttrs = [...form.$('.o_form_label')].map(n => n.getAttribute('for')); + + assert.strictEqual([...new Set(fieldIdAttrs)].length, 4, + "should have generated a unique id for each field occurrence"); + assert.deepEqual(fieldIdAttrs, labelForAttrs, + "the for attribute of labels must coincide with field ids"); + + form.destroy(); + }); + + QUnit.test('input ids for multiple occurrences of fields in sub form view (inline)', async function (assert) { + // A same field can occur several times in the view, but its id must be + // unique by occurrence, otherwise there is a warning in the console (in + // edit mode) as we get several inputs with the same "id" attribute, and + // several labels the same "for" attribute. + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <field name="p"> + <tree><field name="foo"/></tree> + <form> + <group> + <field name="foo"/> + <label for="qux"/> + <div><field name="qux"/></div> + </group> + <group> + <field name="foo"/> + <label for="qux2"/> + <div><field name="qux" id="qux2"/></div> + </group> + </form> + </field> + </form>`, + }); + + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + + assert.containsOnce(document.body, '.modal .o_form_view'); + + const fieldIdAttrs = [...$('.modal .o_form_view .o_field_widget')].map(n => n.getAttribute('id')); + const labelForAttrs = [...$('.modal .o_form_view .o_form_label')].map(n => n.getAttribute('for')); + + assert.strictEqual([...new Set(fieldIdAttrs)].length, 4, + "should have generated a unique id for each field occurrence"); + assert.deepEqual(fieldIdAttrs, labelForAttrs, + "the for attribute of labels must coincide with field ids"); + + form.destroy(); + }); + + QUnit.test('input ids for multiple occurrences of fields in sub form view (not inline)', async function (assert) { + // A same field can occur several times in the view, but its id must be + // unique by occurrence, otherwise there is a warning in the console (in + // edit mode) as we get several inputs with the same "id" attribute, and + // several labels the same "for" attribute. + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="p"/></form>', + archs: { + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,form': ` + <form> + <group> + <field name="foo"/> + <label for="qux"/> + <div><field name="qux"/></div> + </group> + <group> + <field name="foo"/> + <label for="qux2"/> + <div><field name="qux" id="qux2"/></div> + </group> + </form>` + }, + }); + + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + + assert.containsOnce(document.body, '.modal .o_form_view'); + + const fieldIdAttrs = [...$('.modal .o_form_view .o_field_widget')].map(n => n.getAttribute('id')); + const labelForAttrs = [...$('.modal .o_form_view .o_form_label')].map(n => n.getAttribute('for')); + + assert.strictEqual([...new Set(fieldIdAttrs)].length, 4, + "should have generated a unique id for each field occurrence"); + assert.deepEqual(fieldIdAttrs, labelForAttrs, + "the for attribute of labels must coincide with field ids"); + + form.destroy(); + }); + + QUnit.test('two occurrences of invalid field in form view', async function (assert) { + assert.expect(2); + + this.data.partner.fields.trululu.required = true; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <group> + <field name="trululu"/> + <field name="trululu"/> + </group> + </form>`, + }); + + await testUtils.form.clickSave(form); + + assert.containsN(form, '.o_form_label.o_field_invalid', 2); + assert.containsN(form, '.o_field_many2one.o_field_invalid', 2); + + form.destroy(); + }); + + QUnit.test('tooltips on multiple occurrences of fields and labels', async function (assert) { + assert.expect(4); + + const initialDebugMode = odoo.debug; + odoo.debug = false; + + this.data.partner.fields.foo.help = 'foo tooltip'; + this.data.partner.fields.bar.help = 'bar tooltip'; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <group> + <field name="foo"/> + <label for="bar"/> + <div><field name="bar"/></div> + </group> + <group> + <field name="foo"/> + <label for="bar2"/> + <div><field name="bar" id="bar2"/></div> + </group> + </form>`, + }); + + const $fooLabel1 = form.$('.o_form_label:nth(0)'); + $fooLabel1.tooltip('show', false); + $fooLabel1.trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "foo tooltip"); + $fooLabel1.trigger($.Event('mouseleave')); + + const $fooLabel2 = form.$('.o_form_label:nth(2)'); + $fooLabel2.tooltip('show', false); + $fooLabel2.trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "foo tooltip"); + $fooLabel2.trigger($.Event('mouseleave')); + + const $barLabel1 = form.$('.o_form_label:nth(1)'); + $barLabel1.tooltip('show', false); + $barLabel1.trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "bar tooltip"); + $barLabel1.trigger($.Event('mouseleave')); + + const $barLabel2 = form.$('.o_form_label:nth(3)'); + $barLabel2.tooltip('show', false); + $barLabel2.trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "bar tooltip"); + $barLabel2.trigger($.Event('mouseleave')); + + odoo.debug = initialDebugMode; + form.destroy(); + }); + + QUnit.test('readonly attrs on fields are re-evaluated on field change', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" attrs="{\'readonly\': [[\'bar\', \'=\', True]]}"/>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + await testUtils.form.clickEdit(form); + + assert.containsOnce(form, 'span[name="foo"]', + "the foo field widget should be readonly"); + await testUtils.dom.click(form.$('.o_field_boolean input')); + assert.containsOnce(form, 'input[name="foo"]', + "the foo field widget should have been rerendered to now be editable"); + await testUtils.dom.click(form.$('.o_field_boolean input')); + assert.containsOnce(form, 'span[name="foo"]', + "the foo field widget should have been rerendered to now be readonly again"); + await testUtils.dom.click(form.$('.o_field_boolean input')); + assert.containsOnce(form, 'input[name="foo"]', + "the foo field widget should have been rerendered to now be editable again"); + + form.destroy(); + }); + + QUnit.test('empty fields have o_form_empty class in readonly mode', async function (assert) { + assert.expect(8); + + this.data.partner.fields.foo.default = false; // no default value for this test + this.data.partner.records[1].foo = false; // 1 is record with id=2 + this.data.partner.records[1].trululu = false; // 1 is record with id=2 + this.data.partner.fields.int_field.readonly = true; + this.data.partner.onchanges.foo = function (obj) { + if (obj.foo === "hello") { + obj.int_field = false; + } + }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '<field name="trululu" attrs="{\'readonly\': [[\'foo\', \'=\', False]]}"/>' + + '<field name="int_field"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.containsN(form, '.o_field_widget.o_field_empty', 2, + "should have 2 empty fields with correct class"); + assert.containsN(form, '.o_form_label_empty', 2, + "should have 2 muted labels (for the empty fieds) in readonly"); + + await testUtils.form.clickEdit(form); + + assert.containsOnce(form, '.o_field_empty', + "in edit mode, only empty readonly fields should have the o_field_empty class"); + assert.containsOnce(form, '.o_form_label_empty', + "in edit mode, only labels associated to empty readonly fields should have the o_form_label_empty class"); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'test'); + + assert.containsNone(form, '.o_field_empty', + "after readonly modifier change, the o_field_empty class should have been removed"); + assert.containsNone(form, '.o_form_label_empty', + "after readonly modifier change, the o_form_label_empty class should have been removed"); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'hello'); + + assert.containsOnce(form, '.o_field_empty', + "after value changed to false for a readonly field, the o_field_empty class should have been added"); + assert.containsOnce(form, '.o_form_label_empty', + "after value changed to false for a readonly field, the o_form_label_empty class should have been added"); + + form.destroy(); + }); + + QUnit.test('empty fields\' labels still get the empty class after widget rerender', async function (assert) { + assert.expect(6); + + this.data.partner.fields.foo.default = false; // no default value for this test + this.data.partner.records[1].foo = false; // 1 is record with id=2 + this.data.partner.records[1].display_name = false; // 1 is record with id=2 + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<field name="display_name" attrs="{\'readonly\': [[\'foo\', \'=\', \'readonly\']]}"/>' + + '</group>' + + '</form>', + res_id: 2, + }); + + assert.containsN(form, '.o_field_widget.o_field_empty', 2); + assert.containsN(form, '.o_form_label_empty', 2, + "should have 1 muted label (for the empty fied) in readonly"); + + await testUtils.form.clickEdit(form); + + assert.containsNone(form, '.o_field_empty', + "in edit mode, only empty readonly fields should have the o_field_empty class"); + assert.containsNone(form, '.o_form_label_empty', + "in edit mode, only labels associated to empty readonly fields should have the o_form_label_empty class"); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'readonly'); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'edit'); + await testUtils.fields.editInput(form.$('input[name=display_name]'), 'some name'); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'readonly'); + + assert.containsNone(form, '.o_field_empty', + "there still should not be any empty class on fields as the readonly one is now set"); + assert.containsNone(form, '.o_form_label_empty', + "there still should not be any empty class on labels as the associated readonly field is now set"); + + form.destroy(); + }); + + QUnit.test('empty inner readonly fields don\'t have o_form_empty class in "create" mode', async function (assert) { + assert.expect(2); + + this.data.partner.fields.product_id.readonly = true; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<group>' + + '<field name="product_id"/>' + + '</group>' + + '</group>' + + '</sheet>' + + '</form>', + }); + assert.containsNone(form, '.o_form_label_empty', + "no empty class on label"); + assert.containsNone(form, '.o_field_empty', + "no empty class on field"); + form.destroy(); + }); + + QUnit.test('form view can switch to edit mode', async function (assert) { + assert.expect(9); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + assert.hasClass(form.$('.o_form_view'), 'o_form_readonly'); + assert.isVisible(form.$buttons.find('.o_form_buttons_view')); + assert.isNotVisible(form.$buttons.find('.o_form_buttons_edit')); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode'); + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + assert.doesNotHaveClass(form.$('.o_form_view'), 'o_form_readonly'); + assert.isNotVisible(form.$buttons.find('.o_form_buttons_view')); + assert.isVisible(form.$buttons.find('.o_form_buttons_edit')); + form.destroy(); + }); + + QUnit.test('required attrs on fields are re-evaluated on field change', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + await testUtils.form.clickEdit(form); + + assert.containsOnce(form, 'input[name="foo"].o_required_modifier', + "the foo field widget should be required"); + await testUtils.dom.click('.o_field_boolean input'); + assert.containsOnce(form, 'input[name="foo"]:not(.o_required_modifier)', + "the foo field widget should now have been marked as non-required"); + await testUtils.dom.click('.o_field_boolean input'); + assert.containsOnce(form, 'input[name="foo"].o_required_modifier', + "the foo field widget should now have been marked as required again"); + + form.destroy(); + }); + + QUnit.test('required fields should have o_required_modifier in readonly mode', async function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.required = true; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, 'span.o_required_modifier', form); + + await testUtils.form.clickEdit(form); + assert.containsOnce(form, 'input.o_required_modifier', + "in edit mode, should have 1 input with o_required_modifier"); + form.destroy(); + }); + + QUnit.test('required float fields works as expected', async function (assert) { + assert.expect(10); + + this.data.partner.fields.qux.required = true; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="qux"/>' + + '</group>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + + assert.hasClass(form.$('input[name="qux"]'), 'o_required_modifier'); + assert.strictEqual(form.$('input[name="qux"]').val(), "0.0", + "qux input is 0 by default (float field)"); + + await testUtils.form.clickSave(form); + + assert.containsNone(form.$('input[name="qux"]'), "should have switched to readonly"); + + await testUtils.form.clickEdit(form); + + await testUtils.fields.editInput(form.$('input[name=qux]'), '1'); + + await testUtils.form.clickSave(form); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('input[name="qux"]').val(), "1.0", + "qux input is properly formatted"); + + assert.verifySteps(['onchange', 'create', 'read', 'write', 'read']); + form.destroy(); + }); + + QUnit.test('separators', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<separator string="Geolocation"/>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, 'div.o_horizontal_separator'); + form.destroy(); + }); + + QUnit.test('invisible attrs on separators', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<separator string="Geolocation" attrs=\'{"invisible": [["bar", "=", True]]}\'/>'+ + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.hasClass(form.$('div.o_horizontal_separator'), 'o_invisible_modifier'); + + form.destroy(); + }); + + QUnit.test('buttons in form view', async function (assert) { + assert.expect(8); + + var rpcCount = 0; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="state" invisible="1"/>' + + '<header>' + + '<button name="post" class="p" string="Confirm" type="object"/>' + + '<button name="some_method" class="s" string="Do it" type="object"/>' + + '<button name="some_other_method" states="ab,ef" string="Do not" type="object"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<button string="Geolocate" name="geo_localize" icon="fa-check" type="object"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + assert.containsOnce(form, 'button.btn i.fa.fa-check'); + assert.containsN(form, '.o_form_statusbar button', 3); + assert.containsOnce(form, 'button.p[name="post"]:contains(Confirm)'); + assert.containsN(form, '.o_form_statusbar button:visible', 2); + + await testUtils.mock.intercept(form, 'execute_action', function (ev) { + assert.strictEqual(ev.data.action_data.name, 'post', + "should trigger execute_action with correct method name"); + assert.deepEqual(ev.data.env.currentID, 2, "should have correct id in ev data"); + ev.data.on_success(); + ev.data.on_closed(); + }); + rpcCount = 0; + await testUtils.dom.click('.o_form_statusbar button.p', form); + + assert.strictEqual(rpcCount, 1, "should have done 1 rpcs to reload"); + + await testUtils.mock.intercept(form, 'execute_action', function (ev) { + ev.data.on_fail(); + }); + await testUtils.dom.click('.o_form_statusbar button.s', form); + + assert.strictEqual(rpcCount, 1, + "should have done 1 rpc, because we do not reload anymore if the server action fails"); + + form.destroy(); + }); + + QUnit.test('buttons classes in form view', async function (assert) { + assert.expect(16); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<header>' + + '<button name="0"/>' + + '<button name="1" class="btn-primary"/>' + + '<button name="2" class="oe_highlight"/>' + + '<button name="3" class="btn-secondary"/>' + + '<button name="4" class="btn-link"/>' + + '<button name="5" class="oe_link"/>' + + '<button name="6" class="btn-success"/>' + + '<button name="7" class="o_this_is_a_button"/>' + + '</header>' + + '<sheet>' + + '<button name="8"/>' + + '<button name="9" class="btn-primary"/>' + + '<button name="10" class="oe_highlight"/>' + + '<button name="11" class="btn-secondary"/>' + + '<button name="12" class="btn-link"/>' + + '<button name="13" class="oe_link"/>' + + '<button name="14" class="btn-success"/>' + + '<button name="15" class="o_this_is_a_button"/>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.hasAttrValue(form.$('button[name="0"]'), 'class', 'btn btn-secondary'); + assert.hasAttrValue(form.$('button[name="1"]'), 'class', 'btn btn-primary'); + assert.hasAttrValue(form.$('button[name="2"]'), 'class', 'btn btn-primary'); + assert.hasAttrValue(form.$('button[name="3"]'), 'class', 'btn btn-secondary'); + assert.hasAttrValue(form.$('button[name="4"]'), 'class', 'btn btn-link'); + assert.hasAttrValue(form.$('button[name="5"]'), 'class', 'btn btn-link'); + assert.hasAttrValue(form.$('button[name="6"]'), 'class', 'btn btn-success'); + assert.hasAttrValue(form.$('button[name="7"]'), 'class', 'btn o_this_is_a_button btn-secondary'); + assert.hasAttrValue(form.$('button[name="8"]'), 'class', 'btn btn-secondary'); + assert.hasAttrValue(form.$('button[name="9"]'), 'class', 'btn btn-primary'); + assert.hasAttrValue(form.$('button[name="10"]'), 'class', 'btn btn-primary'); + assert.hasAttrValue(form.$('button[name="11"]'), 'class', 'btn btn-secondary'); + assert.hasAttrValue(form.$('button[name="12"]'), 'class', 'btn btn-link'); + assert.hasAttrValue(form.$('button[name="13"]'), 'class', 'btn btn-link'); + assert.hasAttrValue(form.$('button[name="14"]'), 'class', 'btn btn-success'); + assert.hasAttrValue(form.$('button[name="15"]'), 'class', 'btn o_this_is_a_button'); + + form.destroy(); + }); + + QUnit.test('button in form view and long willStart', async function (assert) { + assert.expect(6); + + var rpcCount = 0; + + var FieldChar = fieldRegistry.get('char'); + fieldRegistry.add('asyncwidget', FieldChar.extend({ + willStart: function () { + assert.step('load '+rpcCount); + if (rpcCount === 2) { + return $.Deferred(); + } + return $.Deferred().resolve(); + }, + })); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="state" invisible="1"/>' + + '<header>' + + '<button name="post" class="p" string="Confirm" type="object"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<field name="foo" widget="asyncwidget"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + assert.verifySteps(['load 1']); + + await testUtils.mock.intercept(form, 'execute_action', function (ev) { + ev.data.on_success(); + ev.data.on_closed(); + }); + + await testUtils.dom.click('.o_form_statusbar button.p', form); + assert.verifySteps(['load 2']); + + testUtils.mock.intercept(form, 'execute_action', function (ev) { + ev.data.on_success(); + ev.data.on_closed(); + }); + + await testUtils.dom.click('.o_form_statusbar button.p', form); + assert.verifySteps(['load 3']); + + form.destroy(); + }); + + QUnit.test('buttons in form view, new record', async function (assert) { + // this simulates a situation similar to the settings forms. + assert.expect(7); + + var resID; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<button name="post" class="p" string="Confirm" type="object"/>' + + '<button name="some_method" class="s" string="Do it" type="object"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<button string="Geolocate" name="geo_localize" icon="fa-check" type="object"/>' + + '</group>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'create') { + return this._super.apply(this, arguments).then(function (result) { + resID = result; + return resID; + }); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.mock.intercept(form, 'execute_action', function (event) { + assert.step('execute_action'); + assert.deepEqual(event.data.env.currentID, resID, + "execute action should be done on correct record id"); + event.data.on_success(); + event.data.on_closed(); + }); + await testUtils.dom.click('.o_form_statusbar button.p', form); + + assert.verifySteps(['onchange', 'create', 'read', 'execute_action', 'read']); + form.destroy(); + }); + + QUnit.test('buttons in form view, new record, with field id in view', async function (assert) { + assert.expect(7); + // buttons in form view are one of the rare example of situation when we + // save a record without reloading it immediately, because we only care + // about its id for the next step. But at some point, if the field id + // is in the view, it was registered in the changes, and caused invalid + // values in the record (data.id was set to null) + + var resID; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<button name="post" class="p" string="Confirm" type="object"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<field name="id" invisible="1"/>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'create') { + return this._super.apply(this, arguments).then(function (result) { + resID = result; + return resID; + }); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.mock.intercept(form, 'execute_action', function (event) { + assert.step('execute_action'); + assert.deepEqual(event.data.env.currentID, resID, + "execute action should be done on correct record id"); + event.data.on_success(); + event.data.on_closed(); + }); + await testUtils.dom.click('.o_form_statusbar button.p', form); + + assert.verifySteps(['onchange', 'create', 'read', 'execute_action', 'read']); + form.destroy(); + }); + + QUnit.test('change and save char', async function (assert) { + assert.expect(6); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.ok(true, "should call the /write route"); + } + return this._super(route, args); + }, + res_id: 2, + }); + + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + assert.containsOnce(form, 'span:contains(blip)', + "should contain span with field value"); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode'); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + await testUtils.form.clickSave(form); + + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + assert.containsOnce(form, 'span:contains(tralala)', + "should contain span with field value"); + form.destroy(); + }); + + QUnit.test('properly reload data from server', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'write') { + args.args[1].foo = "apple"; + } + return this._super(route, args); + }, + res_id: 2, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + await testUtils.form.clickSave(form); + assert.containsOnce(form, 'span:contains(apple)', + "should contain span with field value"); + form.destroy(); + }); + + QUnit.test('disable buttons until reload data from server', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'write') { + args.args[1].foo = "apple"; + } else if (args.method === 'read') { + // Block the 'read' call + var result = this._super.apply(this, arguments); + return Promise.resolve(def).then(result); + } + return this._super(route, args); + }, + res_id: 2, + }); + + var def = testUtils.makeTestPromise(); + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + await testUtils.form.clickSave(form); + + // Save button should be disabled + assert.hasAttrValue(form.$buttons.find('.o_form_button_save'), 'disabled', 'disabled'); + // Release the 'read' call + await def.resolve(); + await testUtils.nextTick(); + + // Edit button should be enabled after the reload + assert.hasAttrValue(form.$buttons.find('.o_form_button_edit'), 'disabled', undefined); + + form.destroy(); + }); + + QUnit.test('properly apply onchange in simple case', async function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length + 1000; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/><field name="int_field"/></group>' + + '</form>', + res_id: 2, + }); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('input[name=int_field]').val(), "9", + "should contain input with initial value"); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + assert.strictEqual(form.$('input[name=int_field]').val(), "1007", + "should contain input with onchange applied"); + form.destroy(); + }); + + QUnit.test('properly apply onchange when changed field is active field', async function (assert) { + assert.expect(3); + + this.data.partner.onchanges = { + int_field: function (obj) { + obj.int_field = 14; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="int_field"/></group>' + + '</form>', + res_id: 2, + viewOptions: {mode: 'edit'}, + }); + + + assert.strictEqual(form.$('input[name=int_field]').val(), "9", + "should contain input with initial value"); + + await testUtils.fields.editInput(form.$('input[name=int_field]'), '666'); + + assert.strictEqual(form.$('input[name=int_field]').val(), "14", + "value should have been set to 14 by onchange"); + + await testUtils.form.clickSave(form); + + assert.strictEqual(form.$('.o_field_widget[name=int_field]').text(), "14", + "value should still be 14"); + + form.destroy(); + }); + + QUnit.test('onchange send only the present fields to the server', async function (assert) { + assert.expect(1); + this.data.partner.records[0].product_id = false; + this.data.partner.onchanges.foo = function (obj) { + obj.foo = obj.foo + " alligator"; + }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree>' + + '<field name="bar"/>' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '<field name="timmy"/>' + + '</form>', + archs: { + "partner_type,false,list": '<tree><field name="name"/></tree>' + }, + res_id: 1, + mockRPC: function (route, args) { + if (args.method === "onchange") { + assert.deepEqual(args.args[3], + {"foo": "1", "p": "", "p.bar": "", "p.product_id": "", "timmy": "", "timmy.name": ""}, + "should send only the fields used in the views"); + } + return this._super(route, args); + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + form.destroy(); + }); + + QUnit.test('onchange only send present fields value', async function (assert) { + assert.expect(1); + this.data.partner.onchanges.foo = function (obj) {}; + + let checkOnchange = false; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '<field name="qux"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === "onchange" && checkOnchange) { + assert.deepEqual(args.args[1], { + display_name: "first record", + foo: "tralala", + id: 1, + p: [[0, args.args[1].p[0][1], {"display_name": "valid line", "qux": 12.4}]] + }, "should send the values for the present fields"); + } + return this._super(route, args); + }, + }); + + await testUtils.form.clickEdit(form); + + // add a o2m row + await testUtils.dom.click('.o_field_x2many_list_row_add a'); + form.$('.o_field_one2many input:first').focus(); + await testUtils.nextTick(); + await testUtils.fields.editInput(form.$('.o_field_one2many input[name=display_name]'), 'valid line'); + form.$('.o_field_one2many input:last').focus(); + await testUtils.nextTick(); + await testUtils.fields.editInput(form.$('.o_field_one2many input[name=qux]'), '12.4'); + + // trigger an onchange by modifying foo + checkOnchange = true; + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + form.destroy(); + }); + + QUnit.test('evaluate in python field options', async function (assert) { + assert.expect(1); + + var isOk = false; + var tmp = py.eval; + py.eval = function (expr) { + if (expr === "{'horizontal': true}") { + isOk = true; + } + return tmp.apply(tmp, arguments); + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" options="{\'horizontal\': true}"/>' + + '</form>', + res_id: 2, + }); + + py.eval = tmp; + + assert.ok(isOk, "should have evaluated the field options"); + form.destroy(); + }); + + QUnit.test('can create a record with default values', async function (assert) { + assert.expect(5); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: { + context: {active_field: 2}, + }, + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.strictEqual(args.kwargs.context.active_field, 2, + "should have send the correct context"); + } + return this._super.apply(this, arguments); + }, + }); + var n = this.data.partner.records.length; + + await testUtils.form.clickCreate(form); + assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode'); + + assert.strictEqual(form.$('input:first').val(), "My little Foo Value", + "should have correct default_get value"); + await testUtils.form.clickSave(form); + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + assert.strictEqual(this.data.partner.records.length, n + 1, "should have created a record"); + form.destroy(); + }); + + QUnit.test('default record with a one2many and an onchange on sub field', async function (assert) { + assert.expect(3); + + this.data.partner.onchanges.foo = function () {}; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'onchange') { + assert.deepEqual(args.args[3], { + p: '', + 'p.foo': '1' + }, "onchangeSpec should be correct (with sub fields)"); + } + return this._super.apply(this, arguments); + }, + }); + assert.verifySteps(['onchange']); + form.destroy(); + }); + + QUnit.test('reference field in one2many list', async function (assert) { + assert.expect(1); + + this.data.partner.records[0].reference = 'partner,2'; + + var form = await createView({ + View: FormView, + model: 'user', + data: this.data, + arch: `<form> + <field name="name"/> + <field name="partner_ids"> + <tree editable="bottom"> + <field name="display_name"/> + <field name="reference"/> + </tree> + </field> + </form>`, + archs: { + 'partner,false,form': '<form><field name="display_name"/></form>', + }, + mockRPC: function (route, args) { + if (args.method === 'get_formview_id') { + return Promise.resolve(false); + } + return this._super(route, args); + }, + res_id: 17, + }); + // current form + await testUtils.form.clickEdit(form); + + // open the modal form view of the record pointed by the reference field + await testUtils.dom.click(form.$('table td[title="first record"]')); + await testUtils.dom.click(form.$('table td button.o_external_button')); + + // edit the record in the modal + await testUtils.fields.editInput($('.modal-body input[name="display_name"]'), 'New name'); + await testUtils.dom.click($('.modal-dialog footer button:first-child')); + + assert.containsOnce(form, '.o_field_cell[title="New name"]', 'should not crash and value must be edited'); + + form.destroy(); + }); + + QUnit.test('toolbar is hidden when switching to edit mode', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '</form>', + viewOptions: {hasActionMenus: true}, + res_id: 1, + }); + + assert.containsOnce(form, '.o_cp_action_menus'); + + await testUtils.form.clickEdit(form); + + assert.containsNone(form, '.o_cp_action_menus'); + + await testUtils.form.clickDiscard(form); + + assert.containsOnce(form, '.o_cp_action_menus'); + + form.destroy(); + }); + + QUnit.test('basic default record', async function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.default = "default foo value"; + + var count = 0; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + mockRPC: function (route, args) { + count++; + return this._super(route, args); + }, + }); + + assert.strictEqual(form.$('input[name=foo]').val(), "default foo value", "should have correct default"); + assert.strictEqual(count, 1, "should do only one rpc"); + form.destroy(); + }); + + QUnit.test('make default record with non empty one2many', async function (assert) { + assert.expect(4); + + this.data.partner.fields.p.default = [ + [6, 0, []], // replace with zero ids + [0, 0, {foo: "new foo1", product_id: 41, p: [] }], // create a new value + [0, 0, {foo: "new foo2", product_id: 37, p: [] }], // create a new value + ]; + + var nameGetCount = 0; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'name_get') { + nameGetCount++; + } + return this._super(route, args); + }, + }); + assert.containsOnce(form, 'td:contains(new foo1)', + "should have new foo1 value in one2many"); + assert.containsOnce(form, 'td:contains(new foo2)', + "should have new foo2 value in one2many"); + assert.containsOnce(form, 'td:contains(xphone)', + "should have a cell with the name field 'product_id', set to xphone"); + assert.strictEqual(nameGetCount, 0, "should have done no nameget"); + form.destroy(); + }); + + QUnit.test('make default record with non empty many2one', async function (assert) { + assert.expect(2); + + this.data.partner.fields.trululu.default = 4; + + var nameGetCount = 0; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="trululu"/></form>', + mockRPC: function (route, args) { + if (args.method === 'name_get') { + nameGetCount++; + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), 'aaa', + "default value should be correctly displayed"); + assert.strictEqual(nameGetCount, 0, "should have done no name_get"); + + form.destroy(); + }); + + QUnit.test('form view properly change its title', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record', + "should have the display name of the record as title"); + + await testUtils.form.clickCreate(form); + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), _t("New"), + "should have the display name of the record as title"); + + form.destroy(); + }); + + QUnit.test('archive/unarchive a record', async function (assert) { + assert.expect(10); + + // add active field on partner model to have archive option + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + res_id: 1, + viewOptions: { hasActionMenus: true }, + arch: '<form><field name="active"/><field name="foo"/></form>', + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'action_archive') { + this.data.partner.records[0].active = false; + return Promise.resolve(); + } + if (args.method === 'action_unarchive') { + this.data.partner.records[0].active = true; + return Promise.resolve(); + } + return this._super(...arguments); + }, + }); + + await cpHelpers.toggleActionMenu(form); + assert.containsOnce(form, '.o_cp_action_menus a:contains(Archive)'); + + await cpHelpers.toggleMenuItem(form, "Archive"); + assert.containsOnce(document.body, '.modal'); + + await testUtils.dom.click($('.modal-footer .btn-primary')); + await cpHelpers.toggleActionMenu(form); + assert.containsOnce(form, '.o_cp_action_menus a:contains(Unarchive)'); + + await cpHelpers.toggleMenuItem(form, "Unarchive"); + await cpHelpers.toggleActionMenu(form); + assert.containsOnce(form, '.o_cp_action_menus a:contains(Archive)'); + + assert.verifySteps([ + 'read', + 'action_archive', + 'read', + 'action_unarchive', + 'read', + ]); + + form.destroy(); + }); + + QUnit.test('archive action with active field not in view', async function (assert) { + assert.expect(2); + + // add active field on partner model, but do not put it in the view + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + res_id: 1, + viewOptions: { hasActionMenus: true }, + arch: '<form><field name="foo"/></form>', + }); + + await cpHelpers.toggleActionMenu(form); + assert.containsNone(form, '.o_cp_action_menus a:contains(Archive)'); + assert.containsNone(form, '.o_cp_action_menus a:contains(Unarchive)'); + + form.destroy(); + }); + + QUnit.test('can duplicate a record', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + viewOptions: {hasActionMenus: true}, + }); + + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record', + "should have the display name of the record as title"); + + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Duplicate"); + + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record (copy)', + "should have duplicated the record"); + + assert.strictEqual(form.mode, "edit", 'should be in edit mode'); + form.destroy(); + }); + + QUnit.test('duplicating a record preserve the context', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + viewOptions: {hasActionMenus: true, context: {hey: 'hoy'}}, + mockRPC: function (route, args) { + if (args.method === 'read') { + // should have 2 read, one for initial load, second for + // read after duplicating + assert.strictEqual(args.kwargs.context.hey, 'hoy', + "should have send the correct context"); + } + return this._super.apply(this, arguments); + }, + }); + + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Duplicate"); + + form.destroy(); + }); + + QUnit.test('cannot duplicate a record', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners" duplicate="false">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + viewOptions: {hasActionMenus: true}, + }); + + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record', + "should have the display name of the record as title"); + assert.containsNone(form, '.o_cp_action_menus a:contains(Duplicate)', + "should not contains a 'Duplicate' action"); + form.destroy(); + }); + + QUnit.test('clicking on stat buttons in edit mode', async function (assert) { + assert.expect(9); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<div name="button_box">' + + '<button class="oe_stat_button">' + + '<field name="bar"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].foo, "tralala", "should have saved the changes"); + } + assert.step(args.method); + return this._super(route, args); + }, + }); + + await testUtils.form.clickEdit(form); + + var count = 0; + await testUtils.mock.intercept(form, "execute_action", function (event) { + event.stopPropagation(); + count++; + }); + await testUtils.dom.click('.oe_stat_button'); + assert.strictEqual(count, 1, "should have triggered a execute action"); + assert.strictEqual(form.mode, "edit", "form view should be in edit mode"); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + await testUtils.dom.click('.oe_stat_button:first'); + + assert.strictEqual(form.mode, "edit", "form view should be in edit mode"); + assert.strictEqual(count, 2, "should have triggered a execute action"); + assert.verifySteps(['read', 'write', 'read']); + form.destroy(); + }); + + QUnit.test('clicking on stat buttons save and reload in edit mode', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<div name="button_box">' + + '<button class="oe_stat_button" type="action">' + + '<field name="int_field" widget="statinfo" string="Some number"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="name"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'write') { + // simulate an override of the model... + args.args[1].display_name = "GOLDORAK"; + args.args[1].name = "GOLDORAK"; + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'second record', + "should have correct display_name"); + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=name]'), 'some other name'); + + await testUtils.dom.click('.oe_stat_button'); + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'GOLDORAK', + "should have correct display_name"); + + form.destroy(); + }); + + QUnit.test('buttons with attr "special" do not trigger a save', async function (assert) { + assert.expect(4); + + var executeActionCount = 0; + var writeCount = 0; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<button string="Do something" class="btn-primary" name="abc" type="object"/>' + + '<button string="Or discard" class="btn-secondary" special="cancel"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + writeCount++; + } + return this._super(route, args); + }, + }); + await testUtils.mock.intercept(form, "execute_action", function () { + executeActionCount++; + }); + + await testUtils.form.clickEdit(form); + + // make the record dirty + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + await testUtils.dom.click(form.$('button:contains(Do something)')); + //TODO: VSC: add a next tick ? + assert.strictEqual(writeCount, 1, "should have triggered a write"); + assert.strictEqual(executeActionCount, 1, "should have triggered a execute action"); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'abcdef'); + + await testUtils.dom.click(form.$('button:contains(Or discard)')); + assert.strictEqual(writeCount, 1, "should not have triggered a write"); + assert.strictEqual(executeActionCount, 2, "should have triggered a execute action"); + + form.destroy(); + }); + + QUnit.test('buttons with attr "special=save" save', async function (assert) { + assert.expect(5); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<button string="Save" class="btn-primary" special="save"/>' + + '</form>', + res_id: 1, + intercepts: { + execute_action: function () { + assert.step('execute_action'); + }, + }, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super(route, args); + }, + viewOptions: { + mode: 'edit', + }, + }); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + await testUtils.dom.click(form.$('button[special="save"]')); + assert.verifySteps(['read', 'write', 'read', 'execute_action']); + + form.destroy(); + }); + + QUnit.test('missing widgets do not crash', async function (assert) { + assert.expect(1); + + this.data.partner.fields.foo.type = 'new field type without widget'; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + assert.containsOnce(form, '.o_field_widget'); + form.destroy(); + }); + + QUnit.test('nolabel', async function (assert) { + assert.expect(6); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<group class="firstgroup"><field name="foo" nolabel="1"/></group>' + + '<group class="secondgroup">'+ + '<field name="product_id"/>' + + '<field name="int_field" nolabel="1"/><field name="qux" nolabel="1"/>' + + '</group>' + + '<group><field name="bar"/></group>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.containsN(form, "label.o_form_label", 2); + assert.strictEqual(form.$("label.o_form_label").first().text(), "Product", + "one should be the one for the product field"); + assert.strictEqual(form.$("label.o_form_label").eq(1).text(), "Bar", + "one should be the one for the bar field"); + + assert.hasAttrValue(form.$('.firstgroup td').first(), 'colspan', undefined, + "foo td should have a default colspan (1)"); + assert.containsN(form, '.secondgroup tr', 2, + "int_field and qux should have same tr"); + + assert.containsN(form, '.secondgroup tr:first td', 2, + "product_id field should be on its own tr"); + form.destroy(); + }); + + QUnit.test('many2one in a one2many', async function (assert) { + assert.expect(1); + + this.data.partner.records[0].p = [2]; + this.data.partner.records[1].product_id = 37; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + assert.containsOnce(form, 'td:contains(xphone)', "should display the name of the many2one"); + form.destroy(); + }); + + QUnit.test('circular many2many\'s', async function (assert) { + assert.expect(4); + this.data.partner_type.fields.partner_ids = {string: "partners", type: "many2many", relation: 'partner'}; + this.data.partner.records[0].timmy = [12]; + this.data.partner_type.records[0].partner_ids = [1]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '<form>' + + '<field name="partner_ids">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '<form>' + + '<field name="display_name"/>' + + '</form>' + + '</field>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, 'td:contains(gold)', + "should display the name of the many2many on the original form"); + await testUtils.dom.click(form.$('td:contains(gold)')); + + assert.containsOnce(document.body, '.modal'); + assert.containsOnce($('.modal'), 'td:contains(first record)', + "should display the name of the many2many on the modal form"); + + await testUtils.dom.click('.modal td:contains(first record)'); + assert.containsN(document.body, '.modal', 2, + "there should be 2 modals (partner on top of partner_type) opened"); + + form.destroy(); + }); + + QUnit.test('discard changes on a non dirty form view', async function (assert) { + assert.expect(4); + + var nbWrite = 0; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + res_id: 1, + mockRPC: function (route) { + if (route === '/web/dataset/call_kw/partner/write') { + nbWrite++; + } + return this._super.apply(this, arguments); + }, + }); + + // switch to edit mode + await testUtils.form.clickEdit(form); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', + "input should contain yop"); + + // click on discard + await testUtils.form.clickDiscard(form); + assert.containsNone(document.body, '.modal', 'no confirm modal should be displayed'); + assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop'); + + assert.strictEqual(nbWrite, 0, "no write RPC should have been done"); + form.destroy(); + }); + + QUnit.test('discard changes on a dirty form view', async function (assert) { + assert.expect(7); + + var nbWrite = 0; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + res_id: 1, + mockRPC: function (route) { + if (route === '/web/dataset/call_kw/partner/write') { + nbWrite++; + } + return this._super.apply(this, arguments); + }, + }); + + // switch to edit mode and edit the foo field + await testUtils.form.clickEdit(form); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop"); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should contain new value"); + + // click on discard and cancel the confirm request + await testUtils.form.clickDiscard(form); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); + await testUtils.dom.click('.modal-footer .btn-secondary'); + assert.strictEqual(form.$('input').val(), 'new value', 'input should still contain new value'); + + // click on discard and confirm + await testUtils.form.clickDiscard(form); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); + await testUtils.dom.click('.modal-footer .btn-primary'); + assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop'); + + assert.strictEqual(nbWrite, 0, "no write RPC should have been done"); + form.destroy(); + }); + + QUnit.test('discard changes on a dirty form view (for date field)', async function (assert) { + assert.expect(1); + + // this test checks that the basic model properly handles date object + // when they are discarded and saved. This may be an issue because + // dates are saved as moment object, and were at one point stringified, + // then parsed into string, which is wrong. + + this.data.partner.fields.date.default = "2017-01-25"; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="date"></field></form>', + intercepts: { + history_back: function () { + form.update({}, {reload: false}); + } + }, + }); + + // focus the buttons before clicking on them to precisely reproduce what + // really happens (mostly because the datepicker lib need that focus + // event to properly focusout the input, otherwise it crashes later on + // when the 'blur' event is triggered by the re-rendering) + form.$buttons.find('.o_form_button_cancel').focus(); + await testUtils.dom.click('.o_form_button_cancel'); + form.$buttons.find('.o_form_button_save').focus(); + await testUtils.dom.click('.o_form_button_save'); + assert.containsOnce(form, 'span:contains(2017)'); + + form.destroy(); + }); + + QUnit.test('discard changes on relational data on new record', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><sheet><group>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</group></sheet></form>', + intercepts: { + history_back: function () { + assert.ok(true, "should have sent correct event"); + // simulate the response from the action manager, in the case + // where we have only one active view (the form). If there + // was another view, we would have switched to that view + // instead + form.update({}, {reload: false}); + } + }, + }); + + // edit the p field + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.fields.many2one.clickOpenDropdown('product_id'); + await testUtils.fields.many2one.clickHighlightedItem('product_id'); + + assert.strictEqual(form.$('.o_field_widget[name=product_id] input').val(), 'xphone', + "input should contain xphone"); + + // click on discard and confirm + await testUtils.form.clickDiscard(form); + await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm + + assert.notOk(form.$el.prop('outerHTML').match('xphone'), + "the string xphone should not be present after discarding"); + form.destroy(); + }); + + QUnit.test('discard changes on a new (non dirty, except for defaults) form view', async function (assert) { + assert.expect(3); + + this.data.partner.fields.foo.default = "ABC"; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + intercepts: { + history_back: function () { + assert.ok(true, "should have sent correct event"); + } + } + }); + + // edit the foo field + assert.strictEqual(form.$('input[name=foo]').val(), 'ABC', + "input should contain ABC"); + + await testUtils.form.clickDiscard(form); + + assert.containsNone(document.body, '.modal', + "there should not be a confirm modal"); + + form.destroy(); + }); + + QUnit.test('discard changes on a new (dirty) form view', async function (assert) { + assert.expect(8); + + this.data.partner.fields.foo.default = "ABC"; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + intercepts: { + history_back: function () { + assert.ok(true, "should have sent correct event"); + // simulate the response from the action manager, in the case + // where we have only one active view (the form). If there + // was another view, we would have switched to that view + // instead + form.update({}, {reload: false}); + } + }, + }); + + // edit the foo field + assert.strictEqual(form.$('input').val(), 'ABC', 'input should contain ABC'); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'DEF'); + + // discard the changes and check it has properly been discarded + await testUtils.form.clickDiscard(form); + assert.containsOnce(document.body, '.modal', 'there should be a confirm modal'); + assert.strictEqual(form.$('input').val(), 'DEF', 'input should be DEF'); + await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm + assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC'); + + // redirty and discard the field foo (to make sure initial changes haven't been lost) + await testUtils.fields.editInput(form.$('input[name=foo]'), 'GHI'); + await testUtils.form.clickDiscard(form); + assert.strictEqual(form.$('input').val(), 'GHI', 'input should be GHI'); + await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm + assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC'); + + form.destroy(); + }); + + QUnit.test('discard changes on a duplicated record', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + res_id: 1, + viewOptions: {hasActionMenus: true}, + }); + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + await testUtils.form.clickSave(form); + + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Duplicate"); + + assert.strictEqual(form.$('input[name=foo]').val(), 'tralala', 'input should contain ABC'); + + await testUtils.form.clickDiscard(form); + + assert.containsNone(document.body, '.modal', "there should not be a confirm modal"); + + form.destroy(); + }); + + QUnit.test("switching to another record from a dirty one", async function (assert) { + assert.expect(11); + + var nbWrite = 0; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + mockRPC: function (route) { + if (route === '/web/dataset/call_kw/partner/write') { + nbWrite++; + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should be 1"); + assert.strictEqual(cpHelpers.getPagerSize(form), '2', "pager limit should be 2"); + + // switch to edit mode + await testUtils.form.clickEdit(form); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop"); + + // edit the foo field + await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); + assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value'); + + // click on the pager to switch to the next record and cancel the confirm request + await cpHelpers.pagerNext(form); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); + await testUtils.dom.click('.modal-footer .btn-secondary'); // click on cancel + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should still contain new value"); + assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should still be 1"); + + // click on the pager to switch to the next record and confirm + await cpHelpers.pagerNext(form); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); + await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm + assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "input should contain blip"); + assert.strictEqual(cpHelpers.getPagerValue(form), '2', "pager value should be 2"); + + assert.strictEqual(nbWrite, 0, 'no write RPC should have been done'); + form.destroy(); + }); + + QUnit.test('handling dirty state: switching to another record', async function (assert) { + assert.expect(12); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"></field>' + + '<field name="priority" widget="priority"></field>' + + '</form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + }); + + assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should be 1"); + + // switch to edit mode + await testUtils.form.clickEdit(form); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop"); + + // edit the foo field + await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should contain new value"); + + await testUtils.form.clickSave(form); + + // click on the pager to switch to the next record and cancel the confirm request + await cpHelpers.pagerNext(form); + assert.containsNone(document.body, '.modal:visible', + "no confirm modal should be displayed"); + assert.strictEqual(cpHelpers.getPagerValue(form), '2', "pager value should be 2"); + + assert.containsN(form, '.o_priority .fa-star-o', 2, + 'priority widget should have been rendered with correct value'); + + // edit the value in readonly + await testUtils.dom.click(form.$('.o_priority .fa-star-o:first')); // click on the first star + assert.containsOnce(form, '.o_priority .fa-star', + 'priority widget should have been updated'); + + await cpHelpers.pagerNext(form); + assert.containsNone(document.body, '.modal:visible', + "no confirm modal should be displayed"); + assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should be 1"); + + // switch to edit mode + await testUtils.form.clickEdit(form); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should contain yop"); + + // edit the foo field + await testUtils.fields.editInput(form.$('input[name=foo]'), 'wrong value'); + + await testUtils.form.clickDiscard(form); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); + await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm + await cpHelpers.pagerNext(form); + assert.strictEqual(cpHelpers.getPagerValue(form), '2', "pager value should be 2"); + form.destroy(); + }); + + QUnit.test('restore local state when switching to another record', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<notebook>' + + '<page string="First Page" name="first">' + + '<field name="foo"/>' + + '</page>' + + '<page string="Second page" name="second">' + + '<field name="bar"/>' + + '</page>' + + '</notebook>' + + '</form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + }); + + // click on second page tab + await testUtils.dom.click(form.$('.o_notebook .nav-link:eq(1)')); + + assert.doesNotHaveClass(form.$('.o_notebook .nav-link:eq(0)'), 'active'); + assert.hasClass(form.$('.o_notebook .nav-link:eq(1)'), 'active'); + + // click on the pager to switch to the next record + await cpHelpers.pagerNext(form); + + assert.doesNotHaveClass(form.$('.o_notebook .nav-link:eq(0)'), 'active'); + assert.hasClass(form.$('.o_notebook .nav-link:eq(1)'), 'active'); + form.destroy(); + }); + + QUnit.test('pager is hidden in create mode', async function (assert) { + assert.expect(7); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + viewOptions: { + ids: [1, 2], + index: 0, + }, + }); + + assert.containsOnce(form, '.o_pager'); + assert.strictEqual(cpHelpers.getPagerValue(form), "1", + "current pager value should be 1"); + assert.strictEqual(cpHelpers.getPagerSize(form), "2", + "current pager limit should be 1"); + + await testUtils.form.clickCreate(form); + + assert.containsNone(form, '.o_pager'); + + await testUtils.form.clickSave(form); + + assert.containsOnce(form, '.o_pager'); + assert.strictEqual(cpHelpers.getPagerValue(form), "3", + "current pager value should be 3"); + assert.strictEqual(cpHelpers.getPagerSize(form), "3", + "current pager limit should be 3"); + + form.destroy(); + }); + + QUnit.test('switching to another record, in readonly mode', async function (assert) { + assert.expect(5); + + var pushStateCount = 0; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + intercepts: { + push_state: function (event) { + pushStateCount++; + } + } + }); + + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + assert.strictEqual(cpHelpers.getPagerValue(form), "1", 'pager value should be 1'); + + await cpHelpers.pagerNext(form); + + assert.strictEqual(cpHelpers.getPagerValue(form), "2", 'pager value should be 2'); + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + + assert.strictEqual(pushStateCount, 2, "should have triggered 2 push_state"); + form.destroy(); + }); + + QUnit.test('modifiers are reevaluated when creating new record', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="foo" class="foo_field" attrs=\'{"invisible": [["bar", "=", True]]}\'/>' + + '<field name="bar"/>' + + '</group></sheet>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, 'span.foo_field'); + assert.isNotVisible(form.$('span.foo_field')); + + await testUtils.form.clickCreate(form); + + assert.containsOnce(form, 'input.foo_field'); + assert.isVisible(form.$('input.foo_field')); + + form.destroy(); + }); + + QUnit.test('empty readonly fields are visible on new records', async function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.readonly = true; + this.data.partner.fields.foo.default = undefined; + this.data.partner.records[0].foo = undefined; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="foo"/>' + + '</group></sheet>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, '.o_field_empty'); + + await testUtils.form.clickCreate(form); + + assert.containsNone(form, '.o_field_empty'); + form.destroy(); + }); + + QUnit.test('all group children have correct layout classname', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<group class="inner_group">' + + '<field name="name"/>' + + '</group>' + + '<div class="inner_div">' + + '<field name="foo"/>' + + '</div>' + + '</group></sheet>' + + '</form>', + res_id: 1, + }); + + assert.hasClass(form.$('.inner_group'), 'o_group_col_6'), + assert.hasClass(form.$('.inner_div'), 'o_group_col_6'), + form.destroy(); + }); + + QUnit.test('deleting a record', async function (assert) { + assert.expect(8); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + viewOptions: { + ids: [1, 2, 4], + index: 0, + hasActionMenus: true, + }, + res_id: 1, + }); + + assert.strictEqual(cpHelpers.getPagerValue(form), "1", 'pager value should be 1'); + assert.strictEqual(cpHelpers.getPagerSize(form), "3", 'pager limit should be 3'); + assert.strictEqual(form.$('span:contains(yop)').length, 1, + 'should have a field with foo value for record 1'); + assert.ok(!$('.modal:visible').length, 'no confirm modal should be displayed'); + + // open action menu and delete + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Delete"); + + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + + // confirm the delete + await testUtils.dom.click($('.modal-footer button.btn-primary')); + + assert.strictEqual(cpHelpers.getPagerValue(form), "1", 'pager value should be 1'); + assert.strictEqual(cpHelpers.getPagerSize(form), "2", 'pager limit should be 2'); + assert.strictEqual(form.$('span:contains(blip)').length, 1, + 'should have a field with foo value for record 2'); + form.destroy(); + }); + + QUnit.test('deleting the last record', async function (assert) { + assert.expect(6); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + viewOptions: { + ids: [1], + index: 0, + hasActionMenus: true, + }, + res_id: 1, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + } + }); + + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Delete"); + + await testUtils.mock.intercept(form, 'history_back', function () { + assert.step('history_back'); + }); + assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); + await testUtils.dom.click($('.modal-footer button.btn-primary')); + assert.strictEqual($('.modal').length, 0, 'no confirm modal should be displayed'); + + assert.verifySteps(['read', 'unlink', 'history_back']); + form.destroy(); + }); + + QUnit.test('empty required fields cannot be saved', async function (assert) { + assert.expect(5); + + this.data.partner.fields.foo.required = true; + delete this.data.partner.fields.foo.default; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '</form>', + services: { + notification: NotificationService.extend({ + notify: function (params) { + if (params.type !== 'danger') { + return; + } + assert.strictEqual(params.title, 'Invalid fields:', + "should have a warning with correct title"); + assert.strictEqual(params.message, '<ul><li>Foo</li></ul>', + "should have a warning with correct message"); + } + }), + }, + }); + + await testUtils.form.clickSave(form); + assert.hasClass(form.$('label.o_form_label'),'o_field_invalid'); + assert.hasClass(form.$('input[name=foo]'),'o_field_invalid'); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + assert.containsNone(form, '.o_field_invalid'); + + form.destroy(); + }); + + QUnit.test('changes in a readonly form view are saved directly', async function (assert) { + assert.expect(10); + + var nbWrite = 0; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="foo"/>' + + '<field name="priority" widget="priority"/>' + + '</group>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/dataset/call_kw/partner/write') { + nbWrite++; + } + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + + assert.containsN(form, '.o_priority .o_priority_star', 2, + 'priority widget should have been rendered'); + assert.containsN(form, '.o_priority .fa-star-o', 2, + 'priority widget should have been rendered with correct value'); + + // edit the value in readonly + await testUtils.dom.click(form.$('.o_priority .fa-star-o:first')); + assert.strictEqual(nbWrite, 1, 'should have saved directly'); + assert.containsOnce(form, '.o_priority .fa-star', + 'priority widget should have been updated'); + + // switch to edit mode and edit the value again + await testUtils.form.clickEdit(form); + assert.containsN(form, '.o_priority .o_priority_star', 2, + 'priority widget should have been correctly rendered'); + assert.containsOnce(form, '.o_priority .fa-star', + 'priority widget should have correct value'); + await testUtils.dom.click(form.$('.o_priority .fa-star-o:first')); + assert.strictEqual(nbWrite, 1, 'should not have saved directly'); + assert.containsN(form, '.o_priority .fa-star', 2, + 'priority widget should have been updated'); + + // save + await testUtils.form.clickSave(form); + assert.strictEqual(nbWrite, 2, 'should not have saved directly'); + assert.containsN(form, '.o_priority .fa-star', 2, + 'priority widget should have correct value'); + form.destroy(); + }); + + QUnit.test('display a dialog if onchange result is a warning', async function (assert) { + assert.expect(5); + + this.data.partner.onchanges = { foo: true }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/><field name="int_field"/></group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + value: { int_field: 10 }, + warning: { + title: "Warning", + message: "You must first select a partner", + type: 'dialog', + } + }); + } + return this._super.apply(this, arguments); + }, + intercepts: { + warning: function (event) { + assert.strictEqual(event.data.type, 'dialog', + "should have triggered an event with the correct data"); + assert.strictEqual(event.data.title, "Warning", + "should have triggered an event with the correct data"); + assert.strictEqual(event.data.message, "You must first select a partner", + "should have triggered an event with the correct data"); + }, + }, + }); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('input[name=int_field]').val(), '9'); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + assert.strictEqual(form.$('input[name=int_field]').val(), '10'); + + form.destroy(); + }); + + QUnit.test('display a notificaton if onchange result is a warning with type notification', async function (assert) { + assert.expect(5); + + this.data.partner.onchanges = { foo: true }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/><field name="int_field"/></group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + value: { int_field: 10 }, + warning: { + title: "Warning", + message: "You must first select a partner", + type: 'notification', + } + }); + } + return this._super.apply(this, arguments); + }, + intercepts: { + warning: function (event) { + assert.strictEqual(event.data.type, 'notification', + "should have triggered an event with the correct data"); + assert.strictEqual(event.data.title, "Warning", + "should have triggered an event with the correct data"); + assert.strictEqual(event.data.message, "You must first select a partner", + "should have triggered an event with the correct data"); + }, + }, + }); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('input[name=int_field]').val(), '9'); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + assert.strictEqual(form.$('input[name=int_field]').val(), '10'); + + form.destroy(); + }); + + QUnit.test('can create record even if onchange returns a warning', async function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { foo: true }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/><field name="int_field"/></group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + value: { int_field: 10 }, + warning: { + title: "Warning", + message: "You must first select a partner" + } + }); + } + return this._super.apply(this, arguments); + }, + intercepts: { + warning: function (event) { + assert.ok(true, 'should trigger a warning'); + }, + }, + }); + assert.strictEqual(form.$('input[name="int_field"]').val(), "10", + "record should have been created and rendered"); + + form.destroy(); + }); + + QUnit.test('do nothing if add a line in one2many result in a onchange with a warning', async function (assert) { + assert.expect(3); + + this.data.partner.onchanges = { foo: true }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + value: {}, + warning: { + title: "Warning", + message: "You must first select a partner", + } + }); + } + return this._super.apply(this, arguments); + }, + intercepts: { + warning: function () { + assert.step("should have triggered a warning"); + }, + }, + }); + + // go to edit mode, click to add a record in the o2m + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + assert.containsNone(form, 'tr.o_data_row', + "should not have added a line"); + assert.verifySteps(["should have triggered a warning"]); + form.destroy(); + }); + + QUnit.test('button box is rendered in create mode', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<div name="button_box" class="oe_button_box">' + + '<button type="object" class="oe_stat_button" icon="fa-check-square">' + + '<field name="bar"/>' + + '</button>' + + '</div>' + + '</form>', + res_id: 2, + }); + + // readonly mode + assert.containsOnce(form, '.oe_stat_button', + "button box should be displayed in readonly"); + + // edit mode + await testUtils.form.clickEdit(form); + + assert.containsOnce(form, '.oe_stat_button', + "button box should be displayed in edit on an existing record"); + + // create mode (leave edition first!) + await testUtils.form.clickDiscard(form); + await testUtils.form.clickCreate(form); + assert.containsOnce(form, '.oe_stat_button', + "button box should be displayed when creating a new record as well"); + + form.destroy(); + }); + + QUnit.test('properly apply onchange on one2many fields', async function (assert) { + assert.expect(5); + + this.data.partner.records[0].p = [4]; + this.data.partner.onchanges = { + foo: function (obj) { + obj.p = [ + [5], + [1, 4, {display_name: "updated record"}], + [0, null, {display_name: "created record"}], + ]; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, '.o_field_one2many .o_data_row', + "there should be one one2many record linked at first"); + assert.strictEqual(form.$('.o_field_one2many .o_data_row td:first').text(), 'aaa', + "the 'display_name' of the one2many record should be correct"); + + // switch to edit mode + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange'); + var $o2m = form.$('.o_field_one2many'); + assert.strictEqual($o2m.find('.o_data_row').length, 2, + "there should be two linked record"); + assert.strictEqual($o2m.find('.o_data_row:first td:first').text(), 'updated record', + "the 'display_name' of the first one2many record should have been updated"); + assert.strictEqual($o2m.find('.o_data_row:nth(1) td:first').text(), 'created record', + "the 'display_name' of the second one2many record should be correct"); + + form.destroy(); + }); + + QUnit.test('properly apply onchange on one2many fields direct click', async function (assert) { + assert.expect(3); + + var def = testUtils.makeTestPromise(); + + this.data.partner.records[0].p = [2, 4]; + this.data.partner.onchanges = { + int_field: function (obj) { + obj.p = [ + [5], + [1, 2, {display_name: "updated record 1", int_field: obj.int_field}], + [1, 4, {display_name: "updated record 2", int_field: obj.int_field * 2}], + ]; + }, + }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</group>' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/>' + + '<field name="int_field"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + var self = this; + var my_args = arguments; + var my_super = this._super; + return def.then(() => { + return my_super.apply(self, my_args) + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,false,form': '<form><group><field name="display_name"/><field name="int_field"/></group></form>' + }, + viewOptions: { + mode: 'edit', + }, + }); + // Trigger the onchange + await testUtils.fields.editInput(form.$('input[name=int_field]'), '2'); + + // Open first record in one2many + await testUtils.dom.click(form.$('.o_data_row:first')); + + assert.containsNone(document.body, '.modal'); + + def.resolve(); + await testUtils.nextTick(); + + assert.containsOnce(document.body, '.modal'); + assert.strictEqual($('.modal').find('input[name=int_field]').val(), '2'); + + form.destroy(); + }); + + QUnit.test('update many2many value in one2many after onchange', async function (assert) { + assert.expect(2); + + this.data.partner.records[1].p = [4]; + this.data.partner.onchanges = { + foo: function (obj) { + obj.p = [ + [5], + [1, 4, { + display_name: "gold", + timmy: [[5]], + }], + ]; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name" attrs="{\'readonly\': [(\'timmy\', \'=\', false)]}"/>' + + '<field name="timmy"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 2, + }); + assert.strictEqual($('div[name="p"] .o_data_row td').text().trim(), "aaaNo records", + "should have proper initial content"); + await testUtils.form.clickEdit(form); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); + + assert.strictEqual($('div[name="p"] .o_data_row td').text().trim(), "goldNo records", + "should have proper initial content"); + form.destroy(); + }); + + QUnit.test('delete a line in a one2many while editing another line triggers a warning', async function (assert) { + assert.expect(3); + + this.data.partner.records[0].p = [1, 2]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="display_name" required="True"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_data_cell').first()); + await testUtils.fields.editInput(form.$('input[name=display_name]'), ''); + await testUtils.dom.click(form.$('.fa-trash-o').eq(1)); + + assert.strictEqual($('.modal').find('.modal-title').first().text(), "Warning", + "Clicking out of a dirty line while editing should trigger a warning modal."); + + await testUtils.dom.click($('.modal').find('.btn-primary')); + // use of owlCompatibilityNextTick because there are two sequential updates of the + // control panel (which is written in owl): each of them waits for the next animation frame + // to complete + await testUtils.owlCompatibilityNextTick(); + assert.strictEqual(form.$('.o_data_cell').first().text(), "first record", + "Value should have been reset to what it was before editing began."); + assert.containsOnce(form, '.o_data_row', + "The other line should have been deleted."); + form.destroy(); + }); + + QUnit.test('properly apply onchange on many2many fields', async function (assert) { + assert.expect(14); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.timmy = [ + [5], + [4, 12], + [4, 14], + ]; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '<field name="timmy">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'read' && args.model === 'partner_type') { + assert.deepEqual(args.args[0], [12, 14], + "should read both m2m with one RPC"); + } + if (args.method === 'write') { + assert.deepEqual(args.args[1].timmy, [[6, false, [12, 14]]], + "should correctly save the changed m2m values"); + + } + return this._super.apply(this, arguments); + }, + res_id: 2, + }); + + assert.containsNone(form, '.o_field_many2many .o_data_row', + "there should be no many2many record linked at first"); + + // switch to edit mode + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange'); + var $m2m = form.$('.o_field_many2many'); + assert.strictEqual($m2m.find('.o_data_row').length, 2, + "there should be two linked records"); + assert.strictEqual($m2m.find('.o_data_row:first td:first').text(), 'gold', + "the 'display_name' of the first m2m record should be correctly displayed"); + assert.strictEqual($m2m.find('.o_data_row:nth(1) td:first').text(), 'silver', + "the 'display_name' of the second m2m record should be correctly displayed"); + + await testUtils.form.clickSave(form); + + assert.verifySteps(['read', 'onchange', 'read', 'write', 'read', 'read']); + + form.destroy(); + }); + + QUnit.test('display_name not sent for onchanges if not in view', async function (assert) { + assert.expect(7); + + this.data.partner.records[0].timmy = [12]; + this.data.partner.onchanges = { + foo: function () {}, + }; + this.data.partner_type.onchanges = { + name: function () {}, + }; + var readInModal = false; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="foo"/>' + + '<field name="timmy">' + + '<tree>' + + '<field name="name"/>' + + '</tree>' + + '<form>' + + '<field name="name"/>' + + '<field name="color"/>' + + '</form>' + + '</field>' + + '</group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner') { + assert.deepEqual(args.args[1], ['foo', 'timmy', 'display_name'], + "should read display_name even if not in the view"); + } + if (args.method === 'read' && args.model === 'partner_type') { + if (!readInModal) { + assert.deepEqual(args.args[1], ['name'], + "should not read display_name for records in the list"); + } else { + assert.deepEqual(args.args[1], ['name', 'color', 'display_name'], + "should read display_name when opening the subrecord"); + } + } + if (args.method === 'onchange' && args.model === 'partner') { + assert.deepEqual(args.args[1], { + id: 1, + foo: 'coucou', + timmy: [[6, false, [12]]], + }, "should only send the value of fields in the view (+ id)"); + assert.deepEqual(args.args[3], { + foo: '1', + timmy: '', + 'timmy.name': '1', + 'timmy.color': '', + }, "only the fields in the view should be in the onchange spec"); + } + if (args.method === 'onchange' && args.model === 'partner_type') { + assert.deepEqual(args.args[1], { + id: 12, + name: 'new name', + color: 2, + }, "should only send the value of fields in the view (+ id)"); + assert.deepEqual(args.args[3], { + name: '1', + color: '', + }, "only the fields in the view should be in the onchange spec"); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + // trigger the onchange + await testUtils.fields.editInput(form.$('.o_field_widget[name=foo]'), "coucou"); + + // open a subrecord and trigger an onchange + readInModal = true; + await testUtils.dom.click(form.$('.o_data_row .o_data_cell:first')); + await testUtils.fields.editInput($('.modal .o_field_widget[name=name]'), "new name"); + + form.destroy(); + }); + + QUnit.test('onchanges on date(time) fields', async function (assert) { + assert.expect(6); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.date = '2021-12-12'; + obj.datetime = '2021-12-12 10:55:05'; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="foo"/>' + + '<field name="date"/>' + + '<field name="datetime"/>' + + '</group>' + + '</form>', + res_id: 1, + session: { + getTZOffset: function () { + return 120; + }, + }, + }); + + assert.strictEqual(form.$('.o_field_widget[name=date]').text(), + '01/25/2017', "the initial date should be correct"); + assert.strictEqual(form.$('.o_field_widget[name=datetime]').text(), + '12/12/2016 12:55:05', "the initial datetime should be correct"); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('.o_field_widget[name=date] input').val(), + '01/25/2017', "the initial date should be correct in edit"); + assert.strictEqual(form.$('.o_field_widget[name=datetime] input').val(), + '12/12/2016 12:55:05', "the initial datetime should be correct in edit"); + + // trigger the onchange + await testUtils.fields.editInput(form.$('.o_field_widget[name="foo"]'), "coucou"); + + assert.strictEqual(form.$('.o_field_widget[name=date] input').val(), + '12/12/2021', "the initial date should be correct in edit"); + assert.strictEqual(form.$('.o_field_widget[name=datetime] input').val(), + '12/12/2021 12:55:05', "the initial datetime should be correct in edit"); + + form.destroy(); + }); + + QUnit.test('onchanges are not sent for each keystrokes', async function (assert) { + var done = assert.async(); + assert.expect(5); + + var onchangeNbr = 0; + + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length + 1000; + }, + }; + var def = testUtils.makeTestPromise(); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group><field name="foo"/><field name="int_field"/></group>' + + '</form>', + res_id: 2, + fieldDebounce: 3, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + onchangeNbr++; + return concurrency.delay(3).then(function () { + def.resolve(); + return result; + }); + } + return result; + }, + }); + + await testUtils.form.clickEdit(form); + + testUtils.fields.editInput(form.$('input[name=foo]'), '1'); + assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet"); + testUtils.fields.editInput(form.$('input[name=foo]'), '12'); + assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet"); + + return waitForFinishedOnChange().then(async function () { + assert.strictEqual(onchangeNbr, 1, "one onchange has been called"); + + // add something in the input, then focus another input + await testUtils.fields.editAndTrigger(form.$('input[name=foo]'), '123', ['change']); + assert.strictEqual(onchangeNbr, 2, "one onchange has been called immediately"); + + return waitForFinishedOnChange(); + }).then(function () { + assert.strictEqual(onchangeNbr, 2, "no extra onchange should have been called"); + + form.destroy(); + done(); + }); + + function waitForFinishedOnChange() { + return def.then(function () { + def = testUtils.makeTestPromise(); + return concurrency.delay(0); + }); + } + }); + + QUnit.test('onchanges are not sent for invalid values', async function (assert) { + assert.expect(6); + + this.data.partner.onchanges = { + int_field: function (obj) { + obj.foo = String(obj.int_field); + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group><field name="foo"/><field name="int_field"/></group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickEdit(form); + + // edit int_field, and check that an onchange has been applied + await testUtils.fields.editInput(form.$('input[name="int_field"]'), "123"); + assert.strictEqual(form.$('input[name="foo"]').val(), "123", + "the onchange has been applied"); + + // enter an invalid value in a float, and check that no onchange has + // been applied + await testUtils.fields.editInput(form.$('input[name="int_field"]'), "123a"); + assert.strictEqual(form.$('input[name="foo"]').val(), "123", + "the onchange has not been applied"); + + // save, and check that the int_field input is marked as invalid + await testUtils.form.clickSave(form); + assert.hasClass(form.$('input[name="int_field"]'),'o_field_invalid', + "input int_field is marked as invalid"); + + assert.verifySteps(['read', 'onchange']); + form.destroy(); + }); + + QUnit.test('rpc complete after destroying parent', async function (assert) { + // We just test that there is no crash in this situation + assert.expect(0); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<button name="update_module" type="object" class="o_form_button_update"/>' + + '</form>', + res_id: 2, + intercepts: { + execute_action: function (event) { + form.destroy(); + event.data.on_success(); + } + } + }); + await testUtils.dom.click(form.$('.o_form_button_update')); + }); + + QUnit.test('onchanges that complete after discarding', async function (assert) { + assert.expect(5); + + var def1 = testUtils.makeTestPromise(); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length + 1000; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group><field name="foo"/><field name="int_field"/></group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + assert.step('onchange is done'); + return def1.then(function () { + return result; + }); + } + return result; + }, + }); + + // go into edit mode + assert.strictEqual(form.$('span[name="foo"]').text(), "blip", + "field foo should be displayed to initial value"); + await testUtils.form.clickEdit(form); + + // edit a value + await testUtils.fields.editInput(form.$('input[name=foo]'), '1234'); + + // discard changes + await testUtils.form.clickDiscard(form); + await testUtils.dom.click($('.modal-footer .btn-primary')); + assert.strictEqual(form.$('span[name="foo"]').text(), "blip", + "field foo should still be displayed to initial value"); + + // complete the onchange + def1.resolve(); + await testUtils.nextTick(); + assert.strictEqual(form.$('span[name="foo"]').text(), "blip", + "field foo should still be displayed to initial value"); + assert.verifySteps(['onchange is done']); + + form.destroy(); + }); + + QUnit.test('discarding before save returns', async function (assert) { + assert.expect(4); + + var def = testUtils.makeTestPromise(); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group><field name="foo"/></group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'write') { + return def.then(_.constant(result)); + } + return result; + }, + viewOptions: { + mode: 'edit', + }, + }); + + await testUtils.fields.editInput(form.$('input[name=foo]'), '1234'); + + // save the value and discard directly + await testUtils.form.clickSave(form); + form.discardChanges(); // Simulate click on breadcrumb + + assert.strictEqual(form.$('.o_field_widget[name="foo"]').val(), "1234", + "field foo should still contain new value"); + assert.strictEqual($('.modal').length, 0, + "Confirm dialog should not be displayed"); + + // complete the write + def.resolve(); + await testUtils.nextTick(); + assert.strictEqual($('.modal').length, 0, + "Confirm dialog should not be displayed"); + assert.strictEqual(form.$('.o_field_widget[name="foo"]').text(), "1234", + "value should have been saved and rerendered in readonly"); + + form.destroy(); + }); + + QUnit.test('unchanged relational data is sent for onchanges', async function (assert) { + assert.expect(1); + + this.data.partner.records[1].p = [4]; + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length + 1000; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.deepEqual(args.args[1].p, [[4, 4, false]], + "should send a command for field p even if it hasn't changed"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange'); + + form.destroy(); + }); + + QUnit.test('onchanges on unknown fields of o2m are ignored', async function (assert) { + // many2one fields need to be postprocessed (the onchange returns [id, + // display_name]), but if we don't know the field, we can't know it's a + // many2one, so it isn't ignored, its value is an array instead of a + // dataPoint id, which may cause errors later (e.g. when saving). + assert.expect(2); + + this.data.partner.records[1].p = [4]; + this.data.partner.onchanges = { + foo: function () {}, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</tree>' + + '<form>' + + '<field name="foo"/>' + + '<field name="product_id"/>' + + '</form>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + value: { + p: [ + [5], + [1, 4, { + foo: 'foo changed', + product_id: [37, "xphone"], + }] + ], + }, + }); + } + if (args.method === 'write') { + assert.deepEqual(args.args[1].p, [[1, 4, { + foo: 'foo changed', + }]], "should only write value of known fields"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange'); + await testUtils.owlCompatibilityNextTick(); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed', + "onchange should have been correctly applied on field in o2m list"); + + await testUtils.form.clickSave(form); + + form.destroy(); + }); + + QUnit.test('onchange value are not discarded on o2m edition', async function (assert) { + assert.expect(4); + + this.data.partner.records[1].p = [4]; + this.data.partner.onchanges = { + foo: function () {}, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</tree>' + + '<form>' + + '<field name="foo"/>' + + '<field name="product_id"/>' + + '</form>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + value: { + p: [[5], [1, 4, {foo: 'foo changed'}]], + }, + }); + } + if (args.method === 'write') { + assert.deepEqual(args.args[1].p, [[1, 4, { + foo: 'foo changed', + }]], "should only write value of known fields"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value', + "the initial value should be the default one"); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange'); + await testUtils.owlCompatibilityNextTick(); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed', + "onchange should have been correctly applied on field in o2m list"); + + await testUtils.dom.click(form.$('.o_data_row')); + assert.strictEqual($('.modal .modal-title').text().trim(), 'Open: one2many field', + "the field string is displayed in the modal title"); + assert.strictEqual($('.modal .o_field_widget').val(), 'foo changed', + "the onchange value hasn't been discarded when opening the o2m"); + + form.destroy(); + }); + + QUnit.test('args of onchanges in o2m fields are correct (inline edition)', async function (assert) { + assert.expect(3); + + this.data.partner.records[1].p = [4]; + this.data.partner.fields.p.relation_field = 'rel_field'; + this.data.partner.fields.int_field.default = 14; + this.data.partner.onchanges = { + int_field: function (obj) { + obj.foo = '[' + obj.rel_field.foo + '] ' + obj.int_field; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + }); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value', + "the initial value should be the default one"); + + await testUtils.dom.click(form.$('.o_data_row td:nth(1)')); + await testUtils.fields.editInput(form.$('.o_data_row input:nth(1)'), 77); + + assert.strictEqual(form.$('.o_data_row input:first').val(), '[blip] 77', + "onchange should have been correctly applied"); + + // create a new o2m record + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + assert.strictEqual(form.$('.o_data_row input:first').val(), '[blip] 14', + "onchange should have been correctly applied after default get"); + + form.destroy(); + }); + + QUnit.test('args of onchanges in o2m fields are correct (dialog edition)', async function (assert) { + assert.expect(6); + + this.data.partner.records[1].p = [4]; + this.data.partner.fields.p.relation_field = 'rel_field'; + this.data.partner.fields.int_field.default = 14; + this.data.partner.onchanges = { + int_field: function (obj) { + obj.foo = '[' + obj.rel_field.foo + '] ' + obj.int_field; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<field name="p" string="custom label">' + + '<tree>' + + '<field name="foo"/>' + + '</tree>' + + '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + }); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value', + "the initial value should be the default one"); + + await testUtils.dom.click(form.$('.o_data_row td:first')); + await testUtils.nextTick(); + await testUtils.fields.editInput($('.modal input:nth(1)'), 77); + assert.strictEqual($('.modal input:first').val(), '[blip] 77', + "onchange should have been correctly applied"); + await testUtils.dom.click($('.modal-footer .btn-primary')); + assert.strictEqual(form.$('.o_data_row td:first').text(), '[blip] 77', + "onchange should have been correctly applied"); + + // create a new o2m record + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + assert.strictEqual($('.modal .modal-title').text().trim(), 'Create custom label', + "the custom field label is applied in the modal title"); + assert.strictEqual($('.modal input:first').val(), '[blip] 14', + "onchange should have been correctly applied after default get"); + await testUtils.dom.clickFirst($('.modal-footer .btn-primary')); + await testUtils.nextTick(); + assert.strictEqual(form.$('.o_data_row:nth(1) td:first').text(), '[blip] 14', + "onchange should have been correctly applied after default get"); + + form.destroy(); + }); + + QUnit.test('context of onchanges contains the context of changed fields', async function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { + foo: function () {}, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo" context="{\'test\': 1}"/>' + + '<field name="int_field" context="{\'int_ctx\': 1}"/>' + + '</group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.strictEqual(args.kwargs.context.test, 1, + "the context of the field triggering the onchange should be given"); + assert.strictEqual(args.kwargs.context.int_ctx, undefined, + "the context of other fields should not be given"); + } + return this._super.apply(this, arguments); + }, + res_id: 2, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name=foo]'), 'coucou'); + + form.destroy(); + }); + + QUnit.test('navigation with tab key in form view', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" widget="email"/>' + + '<field name="bar"/>' + + '<field name="display_name" widget="url"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + // go to edit mode + await testUtils.form.clickEdit(form); + + // focus first input, trigger tab + form.$('input[name="foo"]').focus(); + + const tabKey = { keyCode: $.ui.keyCode.TAB, which: $.ui.keyCode.TAB }; + await testUtils.dom.triggerEvent(form.$('input[name="foo"]'), 'keydown', tabKey); + assert.ok($.contains(form.$('div[name="bar"]')[0], document.activeElement), + "bar checkbox should be focused"); + + await testUtils.dom.triggerEvent(document.activeElement, 'keydown', tabKey); + assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement, + "display_name should be focused"); + + // simulate shift+tab on active element + const shiftTabKey = Object.assign({}, tabKey, { shiftKey: true }); + await testUtils.dom.triggerEvent(document.activeElement, 'keydown', shiftTabKey); + await testUtils.dom.triggerEvent(document.activeElement, 'keydown', shiftTabKey); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "first input should be focused"); + + form.destroy(); + }); + + QUnit.test('navigation with tab key in readonly form view', async function (assert) { + assert.expect(3); + + this.data.partner.records[1].product_id = 37; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="trululu"/>' + + '<field name="foo"/>' + + '<field name="product_id"/>' + + '<field name="foo" widget="phone"/>' + + '<field name="display_name" widget="url"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + // focus first field, trigger tab + form.$('[name="trululu"]').focus(); + form.$('[name="trululu"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + form.$('[name="foo"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$('[name="product_id"]')[0], document.activeElement, + "product_id should be focused"); + form.$('[name="product_id"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + form.$('[name="foo"]:eq(1)').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$('[name="display_name"]')[0], document.activeElement, + "display_name should be focused"); + + // simulate shift+tab on active element + $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true})); + $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true})); + $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true})); + $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true})); + assert.strictEqual(document.activeElement, form.$('[name="trululu"]')[0], + "first many2one should be focused"); + + form.destroy(); + }); + + QUnit.test('skip invisible fields when navigating with TAB', async function (assert) { + assert.expect(1); + + this.data.partner.records[0].bar = true; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="foo"/>' + + '<field name="bar" invisible="1"/>' + + '<field name="product_id" attrs=\'{"invisible": [["bar", "=", true]]}\'/>' + + '<field name="int_field"/>' + + '</group></sheet>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + form.$('input[name="foo"]').focus(); + form.$('input[name="foo"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$('input[name="int_field"]')[0], document.activeElement, + "int_field should be focused"); + + form.destroy(); + }); + + QUnit.test('navigation with tab key selects a value in form view', async function (assert) { + assert.expect(5); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <field name="display_name"/> + <field name="int_field"/> + <field name="qux"/> + <field name="trululu"/> + <field name="date"/> + <field name="datetime"/> + </form>`, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + await testUtils.dom.click(form.el.querySelector('input[name="display_name"]')); + await testUtils.fields.triggerKeydown(document.activeElement, 'tab'); + assert.strictEqual(document.getSelection().toString(), "10", + "int_field value should be selected"); + + await testUtils.fields.triggerKeydown(document.activeElement, 'tab'); + assert.strictEqual(document.getSelection().toString(), "0.4", + "qux field value should be selected"); + + await testUtils.fields.triggerKeydown(document.activeElement, 'tab'); + assert.strictEqual(document.getSelection().toString(), "aaa", + "trululu field value should be selected"); + + await testUtils.fields.triggerKeydown(document.activeElement, 'tab'); + assert.strictEqual(document.getSelection().toString(), "01/25/2017", + "date field value should be selected"); + + await testUtils.fields.triggerKeydown(document.activeElement, 'tab'); + assert.strictEqual(document.getSelection().toString(), "12/12/2016 10:55:05", + "datetime field value should be selected"); + + form.destroy(); + }); + + QUnit.test('clicking on a stat button with a context', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<sheet>' + + '<div class="oe_button_box" name="button_box">' + + '<button class="oe_stat_button" type="action" name="1" context="{\'test\': active_id}">' + + '<field name="qux" widget="statinfo"/>' + + '</button>' + + '</div>' + + '</sheet>' + + '</form>', + res_id: 2, + viewOptions: { + context: {some_context: true}, + }, + intercepts: { + execute_action: function (e) { + assert.deepEqual(e.data.action_data.context, { + 'test': 2 + }, "button context should have been evaluated and given to the action, with magicc without previous context"); + }, + }, + }); + + await testUtils.dom.click(form.$('.oe_stat_button')); + + form.destroy(); + }); + + QUnit.test('clicking on a stat button with no context', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<sheet>' + + '<div class="oe_button_box" name="button_box">' + + '<button class="oe_stat_button" type="action" name="1">' + + '<field name="qux" widget="statinfo"/>' + + '</button>' + + '</div>' + + '</sheet>' + + '</form>', + res_id: 2, + viewOptions: { + context: {some_context: true}, + }, + intercepts: { + execute_action: function (e) { + assert.deepEqual(e.data.action_data.context, { + }, "button context should have been evaluated and given to the action, with magic keys but without previous context"); + }, + }, + }); + + await testUtils.dom.click(form.$('.oe_stat_button')); + + form.destroy(); + }); + + QUnit.test('diplay a stat button outside a buttonbox', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<sheet>' + + '<button class="oe_stat_button" type="action" name="1">' + + '<field name="int_field" widget="statinfo"/>' + + '</button>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.containsOnce(form, 'button .o_field_widget', + "a field widget should be display inside the button"); + assert.strictEqual(form.$('button .o_field_widget').children().length, 2, + "the field widget should have 2 children, the text and the value"); + assert.strictEqual(parseInt(form.$('button .o_field_widget .o_stat_value').text()), 9, + "the value rendered should be the same as the field value"); + form.destroy(); + }); + + QUnit.test('diplay something else than a button in a buttonbox', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<div name="button_box" class="oe_button_box">' + + '<button type="object" class="oe_stat_button" icon="fa-check-square">' + + '<field name="bar"/>' + + '</button>' + + '<label/>' + + '</div>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.oe_button_box').children().length, 2, + "button box should contain two children"); + assert.containsOnce(form, '.oe_button_box > .oe_stat_button', + "button box should only contain one button"); + assert.containsOnce(form, '.oe_button_box > label', + "button box should only contain one label"); + + form.destroy(); + }); + + QUnit.test('invisible fields are not considered as visible in a buttonbox', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<div name="button_box" class="oe_button_box">' + + '<field name="foo" invisible="1"/>' + + '<field name="bar" invisible="1"/>' + + '<field name="int_field" invisible="1"/>' + + '<field name="qux" invisible="1"/>' + + '<field name="display_name" invisible="1"/>' + + '<field name="state" invisible="1"/>' + + '<field name="date" invisible="1"/>' + + '<field name="datetime" invisible="1"/>' + + '<button type="object" class="oe_stat_button" icon="fa-check-square"/>' + + '</div>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.oe_button_box').children().length, 1, + "button box should contain only one child"); + assert.hasClass(form.$('.oe_button_box'), 'o_not_full', + "the buttonbox should not be full"); + + form.destroy(); + }); + + QUnit.test('display correctly buttonbox, in large size class', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<div name="button_box" class="oe_button_box">' + + '<button type="object" class="oe_stat_button" icon="fa-check-square">' + + '<field name="bar"/>' + + '</button>' + + '<button type="object" class="oe_stat_button" icon="fa-check-square">' + + '<field name="foo"/>' + + '</button>' + + '</div>' + + '</form>', + res_id: 2, + config: { + device: {size_class: 5}, + }, + }); + + assert.strictEqual(form.$('.oe_button_box').children().length, 2, + "button box should contain two children"); + + form.destroy(); + }); + + QUnit.test('one2many default value creation', async function (assert) { + assert.expect(1); + + this.data.partner.records[0].product_ids = [37]; + this.data.partner.fields.product_ids.default = [ + [0, 0, { name: 'xdroid', partner_type_id: 12 }] + ]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="product_ids" nolabel="1">' + + '<tree editable="top" create="0">' + + '<field name="name" readonly="1"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'create') { + var command = args.args[0].product_ids[0]; + assert.strictEqual(command[2].partner_type_id, 12, + "the default partner_type_id should be equal to 12"); + } + return this._super.apply(this, arguments); + }, + }); + await testUtils.form.clickSave(form); + form.destroy(); + }); + + QUnit.test('many2manys inside one2manys are saved correctly', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="timmy" widget="many2many_tags"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'create') { + var command = args.args[0].p; + assert.deepEqual(command, [[0, command[0][1], { + timmy: [[6, false, [12]]], + }]], "the default partner_type_id should be equal to 12"); + } + return this._super.apply(this, arguments); + }, + }); + + // add a o2m subrecord with a m2m tag + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.fields.many2one.clickOpenDropdown('timmy'); + await testUtils.fields.many2one.clickHighlightedItem('timmy'); + + await testUtils.form.clickSave(form); + + form.destroy(); + }); + + QUnit.test('one2manys (list editable) inside one2manys are saved correctly', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p">' + + '<tree>' + + '<field name="p"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + archs: { + "partner,false,form": '<form>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>' + }, + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0].p, + [[0, args.args[0].p[0][1], { + p: [[0, args.args[0].p[0][2].p[0][1], {display_name: "xtv"}]], + }]], + "create should be called with the correct arguments"); + } + return this._super.apply(this, arguments); + }, + }); + + // add a o2m subrecord + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.dom.click($('.modal-body .o_field_one2many .o_field_x2many_list_row_add a')); + await testUtils.fields.editInput($('.modal-body input'), 'xtv'); + await testUtils.dom.click($('.modal-footer button:first')); + assert.strictEqual($('.modal').length, 0, + "dialog should be closed"); + + var row = form.$('.o_field_one2many .o_list_view .o_data_row'); + assert.strictEqual(row.children()[0].textContent, '1 record', + "the cell should contains the number of record: 1"); + + await testUtils.form.clickSave(form); + + form.destroy(); + }); + + QUnit.test('oe_read_only and oe_edit_only classNames on fields inside groups', async function (assert) { + assert.expect(10); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <group> + <field name="foo" class="oe_read_only"/> + <field name="bar" class="oe_edit_only"/> + </group> + </form>`, + res_id: 1, + }); + + assert.hasClass(form.$('.o_form_view'), 'o_form_readonly', + 'form should be in readonly mode'); + assert.isVisible(form.$('.o_field_widget[name=foo]')); + assert.isVisible(form.$('label:contains(Foo)')); + assert.isNotVisible(form.$('.o_field_widget[name=bar]')); + assert.isNotVisible(form.$('label:contains(Bar)')); + + await testUtils.form.clickEdit(form); + assert.hasClass(form.$('.o_form_view'), 'o_form_editable', + 'form should be in readonly mode'); + assert.isNotVisible(form.$('.o_field_widget[name=foo]')); + assert.isNotVisible(form.$('label:contains(Foo)')); + assert.isVisible(form.$('.o_field_widget[name=bar]')); + assert.isVisible(form.$('label:contains(Bar)')); + + form.destroy(); + }); + + QUnit.test('oe_read_only className is handled in list views', async function (assert) { + assert.expect(7); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '<field name="display_name" class="oe_read_only"/>' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.hasClass(form.$('.o_form_view'), 'o_form_readonly', + 'form should be in readonly mode'); + assert.isVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'), + 'display_name cell should be visible in readonly mode'); + + await testUtils.form.clickEdit(form); + + assert.strictEqual(form.el.querySelector('th[data-name="foo"]').style.width, '100%', + 'As the only visible char field, "foo" should take 100% of the remaining space'); + assert.strictEqual(form.el.querySelector('th.oe_read_only').style.width, '0px', + '"oe_read_only" in edit mode should have a 0px width'); + + assert.hasClass(form.$('.o_form_view'), 'o_form_editable', + 'form should be in edit mode'); + assert.isNotVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'), + 'display_name cell should not be visible in edit mode'); + + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.owlCompatibilityNextTick(); + assert.hasClass(form.$('.o_form_view .o_list_view tbody tr:first input[name="display_name"]'), + 'oe_read_only', 'display_name input should have oe_read_only class'); + + form.destroy(); + }); + + QUnit.test('oe_edit_only className is handled in list views', async function (assert) { + assert.expect(5); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '<field name="display_name" class="oe_edit_only"/>' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.hasClass(form.$('.o_form_view'), 'o_form_readonly', + 'form should be in readonly mode'); + assert.isNotVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'), + 'display_name cell should not be visible in readonly mode'); + + await testUtils.form.clickEdit(form); + assert.hasClass(form.$('.o_form_view'), 'o_form_editable', + 'form should be in edit mode'); + assert.isVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'), + 'display_name cell should be visible in edit mode'); + + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.owlCompatibilityNextTick(); + assert.hasClass(form.$('.o_form_view .o_list_view tbody tr:first input[name="display_name"]'), + 'oe_edit_only', 'display_name input should have oe_edit_only class'); + + form.destroy(); + }); + + QUnit.test('*_view_ref in context are passed correctly', async function (assert) { + var done = assert.async(); + assert.expect(3); + + createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p" context="{\'tree_view_ref\':\'module.tree_view_ref\'}"/>' + + '</sheet>' + + '</form>', + res_id: 1, + intercepts: { + load_views: function (event) { + var context = event.data.context; + assert.strictEqual(context.tree_view_ref, 'module.tree_view_ref', + "context should contain tree_view_ref"); + event.data.on_success(); + } + }, + viewOptions: { + context: {some_context: false}, + }, + mockRPC: function (route, args) { + if (args.method === 'read') { + assert.strictEqual('some_context' in args.kwargs.context && !args.kwargs.context.some_context, true, + "the context should have been set"); + } + return this._super.apply(this, arguments); + }, + }).then(async function (form) { + // reload to check that the record's context hasn't been modified + await form.reload(); + form.destroy(); + done(); + }); + }); + + QUnit.test('non inline subview and create=0 in action context', async function (assert) { + // the create=0 should apply on the main view (form), but not on subviews + assert.expect(2); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="product_ids" mode="kanban"/></form>', + archs: { + "product,false,kanban": `<kanban> + <templates><t t-name="kanban-box"> + <div><field name="name"/></div> + </t></templates> + </kanban>`, + }, + res_id: 1, + viewOptions: { + context: {create: false}, + mode: 'edit', + }, + }); + + assert.containsNone(form, '.o_form_button_create'); + assert.containsOnce(form, '.o-kanban-button-new'); + + form.destroy(); + }); + + QUnit.test('readonly fields with modifiers may be saved', async function (assert) { + // the readonly property on the field description only applies on view, + // this is not a DB constraint. It should be seen as a default value, + // that may be overridden in views, for example with modifiers. So + // basically, a field defined as readonly may be edited. + assert.expect(3); + + this.data.partner.fields.foo.readonly = true; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="foo" attrs="{\'readonly\': [(\'bar\',\'=\',False)]}"/>' + + '<field name="bar"/>' + + '</sheet>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], {foo: 'New foo value'}, + "the new value should be saved"); + } + return this._super.apply(this, arguments); + }, + }); + + // bar being set to true, foo shouldn't be readonly and thus its value + // could be saved, even if in its field description it is readonly + await testUtils.form.clickEdit(form); + + assert.containsOnce(form, 'input[name="foo"]', + "foo field should be editable"); + await testUtils.fields.editInput(form.$('input[name="foo"]'), 'New foo value'); + + await testUtils.form.clickSave(form); + + assert.strictEqual(form.$('.o_field_widget[name=foo]').text(), 'New foo value', + "new value for foo field should have been saved"); + + form.destroy(); + }); + + QUnit.test('readonly set by modifier do not break many2many_tags', async function (assert) { + assert.expect(0); + + this.data.partner.onchanges = { + bar: function (obj) { + obj.timmy = [[6, false, [12]]]; + }, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="bar"/>' + + '<field name="timmy" widget="many2many_tags" attrs="{\'readonly\': [(\'bar\',\'=\',True)]}"/>' + + '</sheet>' + + '</form>', + res_id: 5, + }); + + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_field_widget[name=bar] input')); + + form.destroy(); + }); + + QUnit.test('check if id and active_id are defined', async function (assert) { + assert.expect(2); + + let checkOnchange = false; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p" context="{\'default_trululu\':active_id, \'current_id\':id}">' + + '<tree>' + + '<field name="trululu"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + archs: { + "partner,false,form": '<form><field name="trululu"/></form>' + }, + mockRPC: function (route, args) { + if (args.method === 'onchange' && checkOnchange) { + assert.strictEqual(args.kwargs.context.current_id, false, + "current_id should be false"); + assert.strictEqual(args.kwargs.context.default_trululu, false, + "default_trululu should be false"); + } + return this._super.apply(this, arguments); + }, + }); + + checkOnchange = true; + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + form.destroy(); + }); + + QUnit.test('modifiers are considered on multiple <footer/> tags', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<field name="bar"/>' + + '<footer attrs="{\'invisible\': [(\'bar\',\'=\',False)]}">' + + '<button>Hello</button>' + + '<button>World</button>' + + '</footer>' + + '<footer attrs="{\'invisible\': [(\'bar\',\'!=\',False)]}">' + + '<button>Foo</button>' + + '</footer>' + + '</form>', + res_id: 1, + viewOptions: { + footerToButtons: true, + mode: 'edit', + }, + }); + + assert.deepEqual(getVisibleButtonTexts(), ["Hello", "World"], + "only the first button section should be visible"); + + await testUtils.dom.click(form.$(".o_field_boolean input")); + + assert.deepEqual(getVisibleButtonTexts(), ["Foo"], + "only the second button section should be visible"); + + form.destroy(); + + function getVisibleButtonTexts() { + var $visibleButtons = form.$buttons.find('button:visible'); + return _.map($visibleButtons, function (el) { + return el.innerHTML.trim(); + }); + } + }); + + QUnit.test('buttons in footer are moved to $buttons if necessary', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<footer>' + + '<button string="Create" type="object" class="infooter"/>' + + '</footer>' + + '</form>', + res_id: 1, + viewOptions: {footerToButtons: true}, + }); + + assert.containsOnce(form.$('.o_control_panel'), 'button.infooter'); + assert.containsNone(form.$('.o_form_view'), 'button.infooter'); + + // check that this still works after a reload + await testUtils.form.reload(form); + + assert.containsOnce(form.$('.o_control_panel'), 'button.infooter'); + assert.containsNone(form.$('.o_form_view'), 'button.infooter'); + + form.destroy(); + }); + + QUnit.test('open new record even with warning message', async function (assert) { + assert.expect(3); + + this.data.partner.onchanges = { foo: true }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + warning: { + title: "Warning", + message: "Any warning." + } + }); + } + return this._super.apply(this, arguments); + }, + + }); + await testUtils.form.clickEdit(form); + assert.strictEqual(form.$('input').val(), 'blip', 'input should contain record value'); + await testUtils.fields.editInput(form.$('input[name="foo"]'), "tralala"); + assert.strictEqual(form.$('input').val(), 'tralala', 'input should contain new value'); + + await form.reload({ currentId: false }); + assert.strictEqual(form.$('input').val(), '', + 'input should have no value after reload'); + + form.destroy(); + }); + + QUnit.test('render stat button with string inline', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + res_id: 1, + data: this.data, + arch: '<form string="Manufacturing Orders">' + + '<sheet>' + + '<div class="oe_button_box" name="button_box">' + + '<button string="Inventory Moves" class="oe_stat_button" icon="fa-arrows-v"/>' + + '</div>' + + '</sheet>' + + '</form>', + }); + var $button = form.$('.o_form_view .o_form_sheet .oe_button_box .oe_stat_button span'); + assert.strictEqual($button.text(), "Inventory Moves", + "the stat button should contain a span with the string attribute value"); + form.destroy(); + }); + + QUnit.test('renderer waits for asynchronous fields rendering', async function (assert) { + assert.expect(1); + var done = assert.async(); + + testUtils.createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="bar"/>' + + '<field name="foo" widget="ace"/>' + + '<field name="int_field"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + assert.containsOnce(form, '.ace_editor', + "should have waited for ace to load its dependencies"); + form.destroy(); + done(); + }); + }); + + QUnit.test('open one2many form containing one2many', async function (assert) { + assert.expect(9); + + this.data.partner.records[0].product_ids = [37]; + this.data.product.fields.partner_type_ids = { + string: "one2many partner", type: "one2many", relation: "partner_type", + }; + this.data.product.records[0].partner_type_ids = [12]; + + var form = await createView({ + View: FormView, + model: 'partner', + res_id: 1, + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="product_ids">' + + '<tree create="0">' + + '<field name="display_name"/>' + + '<field name="partner_type_ids"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'product,false,form': + '<form string="Products">' + + '<sheet>' + + '<group>' + + '<field name="partner_type_ids">' + + '<tree create="0">' + + '<field name="display_name"/>' + + '<field name="color"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + }, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + var row = form.$('.o_field_one2many .o_list_view .o_data_row'); + assert.strictEqual(row.children()[1].textContent, '1 record', + "the cell should contains the number of record: 1"); + await testUtils.dom.click(row); + var modal_row = $('.modal-body .o_form_sheet .o_field_one2many .o_list_view .o_data_row'); + assert.strictEqual(modal_row.children().length, 2, + "the row should contains the 2 fields defined in the form view"); + assert.strictEqual($(modal_row).text(), "gold2", + "the value of the fields should be fetched and displayed"); + assert.verifySteps(['read', 'read', 'load_views', 'read', 'read'], + "there should be 4 read rpcs"); + form.destroy(); + }); + + QUnit.test('in edit mode, first field is focused', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</form>', + res_id: 1, + }); + await testUtils.form.clickEdit(form); + + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + assert.strictEqual(form.$('input[name="foo"]')[0].selectionStart, 3, + "cursor should be at the end"); + + form.destroy(); + }); + + QUnit.test('autofocus fields are focused', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="bar"/>' + + '<field name="foo" default_focus="1"/>' + + '</form>', + res_id: 1, + }); + await testUtils.form.clickEdit(form); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + + form.destroy(); + }); + + QUnit.test('correct amount of buttons', async function (assert) { + assert.expect(7); + + var self = this; + var buttons = Array(8).join( + '<button type="object" class="oe_stat_button" icon="fa-check-square">' + + '<field name="bar"/>' + + '</button>' + ); + var statButtonSelector = '.oe_stat_button:not(.dropdown-item, .dropdown-toggle)'; + + var createFormWithDeviceSizeClass = async function (size_class) { + return await createView({ + View: FormView, + model: 'partner', + data: self.data, + arch: '<form>' + + '<div name="button_box" class="oe_button_box">' + + buttons + + '</div>' + + '</form>', + res_id: 2, + config: { + device: {size_class: size_class}, + }, + }); + }; + + var assertFormContainsNButtonsWithSizeClass = async function (size_class, n) { + var form = await createFormWithDeviceSizeClass(size_class); + assert.containsN(form, statButtonSelector, n, 'The form has the expected amount of buttons'); + form.destroy(); + }; + + await assertFormContainsNButtonsWithSizeClass(0, 2); + await assertFormContainsNButtonsWithSizeClass(1, 2); + await assertFormContainsNButtonsWithSizeClass(2, 2); + await assertFormContainsNButtonsWithSizeClass(3, 4); + await assertFormContainsNButtonsWithSizeClass(4, 7); + await assertFormContainsNButtonsWithSizeClass(5, 7); + await assertFormContainsNButtonsWithSizeClass(6, 7); + }); + + QUnit.test('can set bin_size to false in context', async function (assert){ + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + context: { + bin_size: false, + }, + mockRPC: function (route, args) { + assert.strictEqual(args.kwargs.context.bin_size, false, + "bin_size should always be in the context and should be false"); + return this._super(route, args); + } + }); + form.destroy(); + }); + + QUnit.test('no focus set on form when closing many2one modal if lastActivatedFieldIndex is not set', async function (assert) { + assert.expect(8); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '<field name="p"/>' + + '<field name="timmy"/>' + + '<field name="product_ids"/>' + + '<field name="trululu"/>' + + '</form>', + res_id: 2, + archs: { + 'partner,false,list': '<tree><field name="display_name"/></tree>', + 'partner_type,false,list': '<tree><field name="name"/></tree>', + 'partner,false,form': '<form><field name="trululu"/></form>', + 'product,false,list': '<tree><field name="name"/></tree>', + }, + mockRPC: function (route, args) { + if (args.method === 'get_formview_id') { + return Promise.resolve(false); + } + return this._super(route, args); + }, + }); + + // set max-height to have scroll forcefully so that we can test scroll position after modal close + $('.o_content').css({'overflow': 'auto', 'max-height': '300px'}); + // Open many2one modal, lastActivatedFieldIndex will not set as we directly click on external button + await testUtils.form.clickEdit(form); + assert.strictEqual($(".o_content").scrollTop(), 0, "scroll position should be 0"); + + form.$(".o_field_many2one[name='trululu'] .o_input").focus(); + assert.notStrictEqual($(".o_content").scrollTop(), 0, "scroll position should not be 0"); + + await testUtils.dom.click(form.$('.o_external_button')); + // Close modal + await testUtils.dom.click($('.modal').last().find('button[class="close"]')); + assert.notStrictEqual($(".o_content").scrollTop(), 0, + "scroll position should not be 0 after closing modal"); + assert.containsNone(document.body, '.modal', 'There should be no modal'); + assert.doesNotHaveClass($('body'), 'modal-open', 'Modal is not said opened'); + assert.strictEqual(form.renderer.lastActivatedFieldIndex, -1, + "lastActivatedFieldIndex is -1"); + assert.equal(document.activeElement, $('body')[0], + 'body is focused, should not set focus on form widget'); + assert.notStrictEqual(document.activeElement, form.$('.o_field_many2one[name="trululu"] .o_input'), + 'field widget should not be focused when lastActivatedFieldIndex is -1'); + + form.destroy(); + }); + + QUnit.test('in create mode, autofocus fields are focused', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="foo" default_focus="1"/>' + + '</form>', + }); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + + form.destroy(); + }); + + QUnit.test('create with false values', async function (assert) { + assert.expect(1); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="bar"/></group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.strictEqual(args.args[0].bar, false, + "the false value should be given as parameter"); + } + return this._super(route, args); + }, + }); + + await testUtils.form.clickSave(form); + + form.destroy(); + }); + + QUnit.test('autofocus first visible field', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field" invisible="1"/>' + + '<field name="foo"/>' + + '</form>', + }); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + + form.destroy(); + }); + + QUnit.test('no autofocus with disable_autofocus option [REQUIRE FOCUS]', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="foo"/>' + + '</form>', + viewOptions: { + disable_autofocus: true, + }, + }); + assert.notStrictEqual(document.activeElement, form.$('input[name="int_field"]')[0], + "int_field field should not have focus"); + + await form.update({}); + + assert.notStrictEqual(document.activeElement, form.$('input[name="int_field"]')[0], + "int_field field should not have focus"); + + form.destroy(); + }); + + QUnit.test('open one2many form containing many2many_tags', async function (assert) { + assert.expect(4); + + this.data.partner.records[0].product_ids = [37]; + this.data.product.fields.partner_type_ids = { + string: "many2many partner_type", type: "many2many", relation: "partner_type", + }; + this.data.product.records[0].partner_type_ids = [12, 14]; + + var form = await createView({ + View: FormView, + model: 'partner', + res_id: 1, + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="product_ids">' + + '<tree create="0">' + + '<field name="display_name"/>' + + '<field name="partner_type_ids" widget="many2many_tags"/>' + + '</tree>' + + '<form string="Products">' + + '<sheet>' + + '<group>' + + '<label for="partner_type_ids"/>' + + '<div>' + + '<field name="partner_type_ids" widget="many2many_tags"/>' + + '</div>' + + '</group>' + + '</sheet>' + + '</form>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + var row = form.$('.o_field_one2many .o_list_view .o_data_row'); + await testUtils.dom.click(row); + assert.verifySteps(['read', 'read', 'read'], + "there should be 3 read rpcs"); + form.destroy(); + }); + + QUnit.test('onchanges are applied before checking if it can be saved', async function (assert) { + assert.expect(4); + + this.data.partner.onchanges.foo = function (obj) {}; + this.data.partner.fields.foo.required = true; + + var def = testUtils.makeTestPromise(); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="foo"/>' + + '</group></sheet>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + assert.step(args.method); + if (args.method === 'onchange') { + return def.then(function () { + return result; + }); + } + return result; + }, + services: { + notification: NotificationService.extend({ + notify: function (params) { + assert.step(params.type); + } + }), + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name="foo"]'), ''); + await testUtils.form.clickSave(form); + + def.resolve(); + await testUtils.nextTick(); + + assert.verifySteps(['read', 'onchange', 'danger']); + form.destroy(); + }); + + QUnit.test('display toolbar', async function (assert) { + assert.expect(8); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + res_id: 1, + arch: '<form string="Partners">' + + '<group><field name="bar"/></group>' + + '</form>', + toolbar: { + action: [{ + model_name: 'partner', + name: 'Action partner', + type: 'ir.actions.server', + usage: 'ir_actions_server', + }], + print: [], + }, + viewOptions: { + hasActionMenus: true, + }, + mockRPC: function (route, args) { + if (route === '/web/action/load') { + assert.strictEqual(args.context.active_id, 1, + "the active_id shoud be 1."); + assert.deepEqual(args.context.active_ids, [1], + "the active_ids should be an array with 1 inside."); + return Promise.resolve({}); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsNone(form, '.o_cp_action_menus .o_dropdown:contains(Print)'); + assert.containsOnce(form, '.o_cp_action_menus .o_dropdown:contains(Action)'); + + await cpHelpers.toggleActionMenu(form); + + assert.containsN(form, '.o_cp_action_menus .dropdown-item', 3, "there should be 3 actions"); + assert.strictEqual(form.$('.o_cp_action_menus .dropdown-item:last').text().trim(), 'Action partner', + "the custom action should have 'Action partner' as name"); + + await testUtils.mock.intercept(form, 'do_action', function (event) { + var context = event.data.action.context.__contexts[1]; + assert.strictEqual(context.active_id, 1, + "the active_id shoud be 1."); + assert.deepEqual(context.active_ids, [1], + "the active_ids should be an array with 1 inside."); + }); + await cpHelpers.toggleMenuItem(form, "Action partner"); + + form.destroy(); + }); + + QUnit.test('check interactions between multiple FormViewDialogs', async function (assert) { + assert.expect(8); + + this.data.product.fields.product_ids = { + string: "one2many product", type: "one2many", relation: "product", + }; + + this.data.partner.records[0].product_id = 37; + + var form = await createView({ + View: FormView, + model: 'partner', + res_id: 1, + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="product_id"/>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'product,false,form': + '<form string="Products">' + + '<sheet>' + + '<group>' + + '<field name="display_name"/>' + + '<field name="product_ids"/>' + + '</group>' + + '</sheet>' + + '</form>', + 'product,false,list': '<tree><field name="display_name"/></tree>' + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/product/get_formview_id') { + return Promise.resolve(false); + } else if (args.method === 'write') { + assert.strictEqual(args.model, 'product', + "should write on product model"); + assert.strictEqual(args.args[1].product_ids[0][2].display_name, 'xtv', + "display_name of the new object should be xtv"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickEdit(form); + // Open first dialog + await testUtils.dom.click(form.$('.o_external_button')); + assert.strictEqual($('.modal').length, 1, + "One FormViewDialog should be opened"); + var $firstModal = $('.modal'); + assert.strictEqual($('.modal .modal-title').first().text().trim(), 'Open: Product', + "dialog title should display the python field string as label"); + assert.strictEqual($firstModal.find('input').val(), 'xphone', + "display_name should be correctly displayed"); + + // Open second dialog + await testUtils.dom.click($firstModal.find('.o_field_x2many_list_row_add a')); + assert.strictEqual($('.modal').length, 2, + "two FormViewDialogs should be opened"); + var $secondModal = $('.modal:nth(1)'); + // Add new value + await testUtils.fields.editInput($secondModal.find('input'), 'xtv'); + await testUtils.dom.click($secondModal.find('.modal-footer button:first')); + assert.strictEqual($('.modal').length, 1, + "last opened dialog should be closed"); + + // Check that data in first dialog is correctly updated + assert.strictEqual($firstModal.find('tr.o_data_row td').text(), 'xtv', + "should have added a line with xtv as new record"); + await testUtils.dom.click($firstModal.find('.modal-footer button:first')); + form.destroy(); + }); + + QUnit.test('fields and record contexts are not mixed', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="trululu" context="{\'test\': 1}"/>' + + '</group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.strictEqual(args.kwargs.context.test, 1, + "field's context should be sent"); + assert.notOk('mainContext' in args.kwargs.context, + "record's context should not be sent"); + } + return this._super.apply(this, arguments); + }, + res_id: 2, + viewOptions: { + mode: 'edit', + context: {mainContext: 3}, + }, + }); + + await testUtils.dom.click(form.$('.o_field_widget[name=trululu] input')); + + form.destroy(); + }); + + QUnit.test('do not activate an hidden tab when switching between records', async function (assert) { + assert.expect(6); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="Foo" attrs=\'{"invisible": [["id", "=", 2]]}\'>' + + '<field name="foo"/>' + + '</page>' + + '<page string="Bar">' + + '<field name="bar"/>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + }); + + assert.strictEqual(form.$('.o_notebook .nav-item:not(.o_invisible_modifier)').length, 2, + "both tabs should be visible"); + assert.hasClass(form.$('.o_notebook .nav-link:first'),'active', + "first tab should be active"); + + // click on the pager to switch to the next record + await cpHelpers.pagerNext(form); + + assert.strictEqual(form.$('.o_notebook .nav-item:not(.o_invisible_modifier)').length, 1, + "only the second tab should be visible"); + assert.hasClass(form.$('.o_notebook .nav-item:not(.o_invisible_modifier) .nav-link'),'active', + "the visible tab should be active"); + + // click on the pager to switch back to the previous record + await cpHelpers.pagerPrevious(form); + + assert.strictEqual(form.$('.o_notebook .nav-item:not(.o_invisible_modifier)').length, 2, + "both tabs should be visible again"); + assert.hasClass(form.$('.o_notebook .nav-link:nth(1)'),'active', + "second tab should be active"); + + form.destroy(); + }); + + QUnit.test('support anchor tags with action type', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<a type="action" name="42"><i class="fa fa-arrow-right"/> Click me !</a>' + + '</form>', + res_id: 1, + intercepts: { + do_action: function (event) { + assert.strictEqual(event.data.action, "42", + "should trigger do_action with correct action parameter"); + } + } + }); + await testUtils.dom.click(form.$('a[type="action"]')); + + form.destroy(); + }); + + QUnit.test('do not perform extra RPC to read invisible many2one fields', async function (assert) { + // This test isn't really meaningful anymore, since default_get and (first) onchange rpcs + // have been merged in a single onchange rpc, returning nameget for many2one fields. But it + // isn't really costly, and it still checks rpcs done when creating a new record with a m2o. + assert.expect(2); + + this.data.partner.fields.trululu.default = 2; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="trululu" invisible="1"/>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + + assert.verifySteps(['onchange'], "only one RPC should have been done"); + + form.destroy(); + }); + + QUnit.test('do not perform extra RPC to read invisible x2many fields', async function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = [2]; // one2many + this.data.partner.records[0].product_ids = [37]; // one2many + this.data.partner.records[0].timmy = [12]; // many2many + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p" invisible="1"/>' + // no inline view + '<field name="product_ids" invisible="1">' + // inline view + '<tree><field name="display_name"/></tree>' + + '</field>' + + '<field name="timmy" invisible="1" widget="many2many_tags"/>' + // no view + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + + assert.verifySteps(['read'], "only one read should have been done"); + + form.destroy(); + }); + + QUnit.test('default_order on x2many embedded view', async function (assert) { + assert.expect(11); + + this.data.partner.fields.display_name.sortable = true; + this.data.partner.records[0].p = [1, 4]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p">' + + '<tree default_order="foo desc">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,form': + '<form string="Partner">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + }, + res_id: 1, + }); + + assert.ok(form.$('.o_field_one2many tbody tr:first td:contains(yop)').length, + "record 1 should be first"); + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + assert.strictEqual($('.modal').length, 1, + "FormViewDialog should be opened"); + await testUtils.fields.editInput($('.modal input[name="foo"]'), 'xop'); + await testUtils.dom.click($('.modal-footer button:eq(1)')); + await testUtils.fields.editInput($('.modal input[name="foo"]'), 'zop'); + await testUtils.dom.click($('.modal-footer button:first')); + + // client-side sort + assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zop)').length, + "record zop should be first"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').length, + "record yop should be second"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length, + "record xop should be third"); + + // server-side sort + await testUtils.form.clickSave(form); + assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zop)').length, + "record zop should be first"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').length, + "record yop should be second"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length, + "record xop should be third"); + + // client-side sort on edit + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)')); + await testUtils.fields.editInput($('.modal input[name="foo"]'), 'zzz'); + await testUtils.dom.click($('.modal-footer button:first')); + assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zzz)').length, + "record zzz should be first"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(zop)').length, + "record zop should be second"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length, + "record xop should be third"); + + form.destroy(); + }); + + QUnit.test('action context is used when evaluating domains', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="trululu" domain="[(\'id\', \'in\', context.get(\'product_ids\', []))]"/>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: { + context: {product_ids: [45,46,47]} + }, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.deepEqual(args.kwargs.args[0], ['id', 'in', [45,46,47]], + "domain should be properly evaluated"); + } + return this._super.apply(this, arguments); + }, + }); + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('div[name="trululu"] input')); + + form.destroy(); + }); + + QUnit.test('form rendering with groups with col/colspan', async function (assert) { + assert.expect(45); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<sheet>' + + '<group col="6" class="parent_group">' + + '<group col="4" colspan="3" class="group_4">' + + '<div colspan="3"/>' + + '<div colspan="2"/><div/>' + + '<div colspan="4"/>' + + '</group>' + + '<group col="3" colspan="4" class="group_3">' + + '<group col="1" class="group_1">' + + '<div/><div/><div/>' + + '</group>' + + '<div/>' + + '<group col="3" class="field_group">' + + '<field name="foo" colspan="3"/>' + + '<div/><field name="bar" nolabel="1"/>' + + '<field name="qux"/>' + + '<field name="int_field" colspan="3" nolabel="1"/>' + + '<span/><field name="product_id"/>' + + '</group>' + + '</group>' + + '</group>' + + '<group>' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + var $parentGroup = form.$('.parent_group'); + var $group4 = form.$('.group_4'); + var $group3 = form.$('.group_3'); + var $group1 = form.$('.group_1'); + var $fieldGroup = form.$('.field_group'); + + // Verify outergroup/innergroup + assert.strictEqual($parentGroup[0].tagName, 'DIV', ".parent_group should be an outergroup"); + assert.strictEqual($group4[0].tagName, 'TABLE', ".group_4 should be an innergroup"); + assert.strictEqual($group3[0].tagName, 'DIV', ".group_3 should be an outergroup"); + assert.strictEqual($group1[0].tagName, 'TABLE', ".group_1 should be an innergroup"); + assert.strictEqual($fieldGroup[0].tagName, 'TABLE', ".field_group should be an innergroup"); + + // Verify .parent_group content + var $parentGroupChildren = $parentGroup.children(); + assert.strictEqual($parentGroupChildren.length, 2, "there should be 2 groups in .parent_group"); + assert.ok($parentGroupChildren.eq(0).is('.o_group_col_6'), "first .parent_group group should be 1/2 parent width"); + assert.ok($parentGroupChildren.eq(1).is('.o_group_col_8'), "second .parent_group group should be 2/3 parent width"); + + // Verify .group_4 content + var $group4rows = $group4.find('> tbody > tr'); + assert.strictEqual($group4rows.length, 3, "there should be 3 rows in .group_4"); + var $group4firstRowTd = $group4rows.eq(0).children('td'); + assert.strictEqual($group4firstRowTd.length, 1, "there should be 1 td in first row"); + assert.hasAttrValue($group4firstRowTd, 'colspan', "3", "the first td colspan should be 3"); + assert.strictEqual($group4firstRowTd.attr('style').substr(0, 9), "width: 75", "the first td should be 75% width"); + assert.strictEqual($group4firstRowTd.children()[0].tagName, "DIV", "the first td should contain a div"); + var $group4secondRowTds = $group4rows.eq(1).children('td'); + assert.strictEqual($group4secondRowTds.length, 2, "there should be 2 tds in second row"); + assert.hasAttrValue($group4secondRowTds.eq(0), 'colspan', "2", "the first td colspan should be 2"); + assert.strictEqual($group4secondRowTds.eq(0).attr('style').substr(0, 9), "width: 50", "the first td be 50% width"); + assert.hasAttrValue($group4secondRowTds.eq(1), 'colspan', undefined, "the second td colspan should be default one (1)"); + assert.strictEqual($group4secondRowTds.eq(1).attr('style').substr(0, 9), "width: 25", "the second td be 75% width"); + var $group4thirdRowTd = $group4rows.eq(2).children('td'); + assert.strictEqual($group4thirdRowTd.length, 1, "there should be 1 td in third row"); + assert.hasAttrValue($group4thirdRowTd, 'colspan', "4", "the first td colspan should be 4"); + assert.strictEqual($group4thirdRowTd.attr('style').substr(0, 10), "width: 100", "the first td should be 100% width"); + + // Verify .group_3 content + assert.strictEqual($group3.children().length, 3, ".group_3 should have 3 children"); + assert.strictEqual($group3.children('.o_group_col_4').length, 3, ".group_3 should have 3 children of 1/3 width"); + + // Verify .group_1 content + assert.strictEqual($group1.find('> tbody > tr').length, 3, "there should be 3 rows in .group_1"); + + // Verify .field_group content + var $fieldGroupRows = $fieldGroup.find('> tbody > tr'); + assert.strictEqual($fieldGroupRows.length, 5, "there should be 5 rows in .field_group"); + var $fieldGroupFirstRowTds = $fieldGroupRows.eq(0).children('td'); + assert.strictEqual($fieldGroupFirstRowTds.length, 2, "there should be 2 tds in first row"); + assert.hasClass($fieldGroupFirstRowTds.eq(0),'o_td_label', "first td should be a label td"); + assert.hasAttrValue($fieldGroupFirstRowTds.eq(1), 'colspan', "2", "second td colspan should be given colspan (3) - 1 (label)"); + assert.strictEqual($fieldGroupFirstRowTds.eq(1).attr('style').substr(0, 10), "width: 100", "second td width should be 100%"); + var $fieldGroupSecondRowTds = $fieldGroupRows.eq(1).children('td'); + assert.strictEqual($fieldGroupSecondRowTds.length, 2, "there should be 2 tds in second row"); + assert.hasAttrValue($fieldGroupSecondRowTds.eq(0), 'colspan', undefined, "first td colspan should be default one (1)"); + assert.strictEqual($fieldGroupSecondRowTds.eq(0).attr('style').substr(0, 9), "width: 33", "first td width should be 33.3333%"); + assert.hasAttrValue($fieldGroupSecondRowTds.eq(1), 'colspan', undefined, "second td colspan should be default one (1)"); + assert.strictEqual($fieldGroupSecondRowTds.eq(1).attr('style').substr(0, 9), "width: 33", "second td width should be 33.3333%"); + var $fieldGroupThirdRowTds = $fieldGroupRows.eq(2).children('td'); // new row as label/field pair colspan is greater than remaining space + assert.strictEqual($fieldGroupThirdRowTds.length, 2, "there should be 2 tds in third row"); + assert.hasClass($fieldGroupThirdRowTds.eq(0),'o_td_label', "first td should be a label td"); + assert.hasAttrValue($fieldGroupThirdRowTds.eq(1), 'colspan', undefined, "second td colspan should be default one (1)"); + assert.strictEqual($fieldGroupThirdRowTds.eq(1).attr('style').substr(0, 9), "width: 50", "second td should be 50% width"); + var $fieldGroupFourthRowTds = $fieldGroupRows.eq(3).children('td'); + assert.strictEqual($fieldGroupFourthRowTds.length, 1, "there should be 1 td in fourth row"); + assert.hasAttrValue($fieldGroupFourthRowTds, 'colspan', "3", "the td should have a colspan equal to 3"); + assert.strictEqual($fieldGroupFourthRowTds.attr('style').substr(0, 10), "width: 100", "the td should have 100% width"); + var $fieldGroupFifthRowTds = $fieldGroupRows.eq(4).children('td'); // label/field pair can be put after the 1-colspan span + assert.strictEqual($fieldGroupFifthRowTds.length, 3, "there should be 3 tds in fourth row"); + assert.strictEqual($fieldGroupFifthRowTds.eq(0).attr('style').substr(0, 9), "width: 50", "the first td should 50% width"); + assert.hasClass($fieldGroupFifthRowTds.eq(1),'o_td_label', "the second td should be a label td"); + assert.strictEqual($fieldGroupFifthRowTds.eq(2).attr('style').substr(0, 9), "width: 50", "the third td should 50% width"); + + form.destroy(); + }); + + QUnit.test('outer and inner groups string attribute', async function (assert) { + assert.expect(5); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group string="parent group" class="parent_group">' + + '<group string="child group 1" class="group_1">' + + '<field name="bar"/>' + + '</group>' + + '<group string="child group 2" class="group_2">' + + '<field name="bar"/>' + + '</group>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + var $parentGroup = form.$('.parent_group'); + var $group1 = form.$('.group_1'); + var $group2 = form.$('.group_2'); + + assert.containsN(form, 'table.o_inner_group', 2, + "should contain two inner groups"); + assert.strictEqual($group1.find('.o_horizontal_separator').length, 1, + "inner group should contain one string separator"); + assert.strictEqual($group1.find('.o_horizontal_separator:contains(child group 1)').length, 1, + "first inner group should contain 'child group 1' string"); + assert.strictEqual($group2.find('.o_horizontal_separator:contains(child group 2)').length, 1, + "second inner group should contain 'child group 2' string"); + assert.strictEqual($parentGroup.find('> div.o_horizontal_separator:contains(parent group)').length, 1, + "outer group should contain 'parent group' string"); + + form.destroy(); + }); + + QUnit.test('form group with newline tag inside', async function (assert) { + assert.expect(6); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<sheet>' + + '<group col="5" class="main_inner_group">' + + // col=5 otherwise the test is ok even without the + // newline code as this will render a <newline/> DOM + // element in the third column, leaving no place for + // the next field and its label on the same line. + '<field name="foo"/>' + + '<newline/>' + + '<field name="bar"/>' + + '<field name="qux"/>' + + '</group>' + + '<group col="3">' + + // col=3 otherwise the test is ok even without the + // newline code as this will render a <newline/> DOM + // element with the o_group_col_6 class, leaving no + // place for the next group on the same line. + '<group class="top_group">' + + '<div style="height: 200px;"/>' + + '</group>' + + '<newline/>' + + '<group class="bottom_group">' + + '<div/>' + + '</group>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + // Inner group + assert.containsN(form, '.main_inner_group > tbody > tr', 2, + "there should be 2 rows in the group"); + assert.containsOnce(form, '.main_inner_group > tbody > tr:first > .o_td_label', + "there should be only one label in the first row"); + assert.containsOnce(form, '.main_inner_group > tbody > tr:first .o_field_widget', + "there should be only one widget in the first row"); + assert.containsN(form, '.main_inner_group > tbody > tr:last > .o_td_label', 2, + "there should be two labels in the second row"); + assert.containsN(form, '.main_inner_group > tbody > tr:last .o_field_widget', 2, + "there should be two widgets in the second row"); + + // Outer group + assert.ok((form.$('.bottom_group').position().top - form.$('.top_group').position().top) >= 200, + "outergroup children should not be on the same line"); + + form.destroy(); + }); + + QUnit.test('custom open record dialog title', async function (assert) { + assert.expect(1); + + this.data.partner.records[0].p = [2]; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p" widget="many2many" string="custom label">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '<form>' + + '<field name="display_name"/>' + + '</form>' + + '</field>' + + '</form>', + session: {}, + res_id: 1, + }); + + await testUtils.dom.click(form.$('.o_data_row:first')); + assert.strictEqual($('.modal .modal-title').first().text().trim(), 'Open: custom label', + "modal should use the python field string as title"); + + form.destroy(); + }); + + QUnit.test('display translation alert', async function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.translate = true; + this.data.partner.fields.display_name.translate = true; + + var multi_lang = _t.database.multi_lang; + _t.database.multi_lang = true; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '<field name="display_name"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name="foo"]'), "test"); + await testUtils.form.clickSave(form); + assert.containsOnce(form, '.o_form_view .alert > div .oe_field_translate', + "should have single translation alert"); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name="display_name"]'), "test2"); + await testUtils.form.clickSave(form); + assert.containsN(form, '.o_form_view .alert > div .oe_field_translate', 2, + "should have two translate fields in translation alert"); + + form.destroy(); + + _t.database.multi_lang = multi_lang; + }); + + QUnit.test('translation alerts are preserved on pager change', async function (assert) { + assert.expect(5); + + this.data.partner.fields.foo.translate = true; + + var multi_lang = _t.database.multi_lang; + _t.database.multi_lang = true; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '</form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name="foo"]'), "test"); + await testUtils.form.clickSave(form); + + assert.containsOnce(form, '.o_form_view .alert > div', "should have a translation alert"); + + // click on the pager to switch to the next record + await cpHelpers.pagerNext(form); + assert.containsNone(form, '.o_form_view .alert > div', "should not have a translation alert"); + + // click on the pager to switch back to the previous record + await cpHelpers.pagerPrevious(form); + assert.containsOnce(form, '.o_form_view .alert > div', "should have a translation alert"); + + // remove translation alert by click X and check alert even after form reload + await testUtils.dom.click(form.$('.o_form_view .alert > .close')); + assert.containsNone(form, '.o_form_view .alert > div', "should not have a translation alert"); + + await form.reload(); + assert.containsNone(form, '.o_form_view .alert > div', "should not have a translation alert after reload"); + + form.destroy(); + _t.database.multi_lang = multi_lang; + }); + + QUnit.test('translation alerts preseved on reverse breadcrumb', async function (assert) { + assert.expect(2); + + this.data['ir.translation'] = { + fields: { + name: { string: "name", type: "char" }, + source: {string: "Source", type: "char"}, + value: {string: "Value", type: "char"}, + }, + records: [], + }; + + this.data.partner.fields.foo.translate = true; + + var multi_lang = _t.database.multi_lang; + _t.database.multi_lang = true; + + var archs = { + 'partner,false,form': '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '</form>', + 'partner,false,search': '<search></search>', + 'ir.translation,false,list': '<tree>' + + '<field name="name"/>' + + '<field name="source"/>' + + '<field name="value"/>' + + '</tree>', + 'ir.translation,false,search': '<search></search>', + }; + + var actions = [{ + id: 1, + name: 'Partner', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'form']], + }, { + id: 2, + name: 'Translate', + res_model: 'ir.translation', + type: 'ir.actions.act_window', + views: [[false, 'list']], + target: 'current', + flags: {'search_view': true, 'action_buttons': true}, + }]; + + var actionManager = await createActionManager({ + actions: actions, + archs: archs, + data: this.data, + }); + + await actionManager.doAction(1); + actionManager.$('input[name="foo"]').val("test").trigger("input"); + await testUtils.dom.click(actionManager.$('.o_form_button_save')); + + assert.strictEqual(actionManager.$('.o_form_view .alert > div').length, 1, + "should have a translation alert"); + + var currentController = actionManager.getCurrentController().widget; + await actionManager.doAction(2, { + on_reverse_breadcrumb: function () { + if (!_.isEmpty(currentController.renderer.alertFields)) { + currentController.renderer.displayTranslationAlert(); + } + return false; + }, + }); + + await testUtils.dom.click($('.o_control_panel .breadcrumb a:first')); + assert.strictEqual(actionManager.$('.o_form_view .alert > div').length, 1, + "should have a translation alert"); + + actionManager.destroy(); + _t.database.multi_lang = multi_lang; + }); + + QUnit.test('translate event correctly handled with multiple controllers', async function (assert) { + assert.expect(3); + + this.data.product.fields.name.translate = true; + this.data.partner.records[0].product_id = 37; + var nbTranslateCalls = 0; + + var multi_lang = _t.database.multi_lang; + _t.database.multi_lang = true; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="product_id"/>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'product,false,form': '<form>' + + '<sheet>' + + '<group>' + + '<field name="name"/>' + + '<field name="partner_type_id"/>' + + '</group>' + + '</sheet>' + + '</form>', + }, + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/product/get_formview_id') { + return Promise.resolve(false); + } + if (route === "/web/dataset/call_button" && args.method === 'translate_fields') { + assert.deepEqual(args.args, ["product",37,"name"], 'should call "call_button" route'); + nbTranslateCalls++; + return Promise.resolve({ + domain: [], + context: {search_default_name: 'partnes,foo'}, + }); + } + if (route === "/web/dataset/call_kw/res.lang/get_installed") { + return Promise.resolve([["en_US"], ["fr_BE"]]); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('[name="product_id"] .o_external_button')); + assert.containsOnce($('.modal-body'), 'span.o_field_translate', + "there should be a translate button in the modal"); + + await testUtils.dom.click($('.modal-body span.o_field_translate')); + assert.strictEqual(nbTranslateCalls, 1, "should call_button translate once"); + + form.destroy(); + _t.database.multi_lang = multi_lang; + }); + + QUnit.test('buttons are disabled until status bar action is resolved', async function (assert) { + assert.expect(9); + + var def = testUtils.makeTestPromise(); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<button name="post" class="p" string="Confirm" type="object"/>' + + '<button name="some_method" class="s" string="Do it" type="object"/>' + + '</header>' + + '<sheet>' + + '<div name="button_box" class="oe_button_box">' + + '<button class="oe_stat_button">' + + '<field name="bar"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + intercepts: { + execute_action: function (event) { + return def.then(function() { + event.data.on_success(); + }); + } + }, + }); + + assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4, + "control panel buttons should be enabled"); + assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2, + "status bar buttons should be enabled"); + assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1, + "stat buttons should be enabled"); + + await testUtils.dom.clickFirst(form.$('.o_form_statusbar button')); + + // The unresolved promise lets us check the state of the buttons + assert.strictEqual(form.$buttons.find('button:disabled').length, 4, + "control panel buttons should be disabled"); + assert.containsN(form, '.o_form_statusbar button:disabled', 2, + "status bar buttons should be disabled"); + assert.containsOnce(form, '.oe_button_box button:disabled', + "stat buttons should be disabled"); + + def.resolve(); + await testUtils.nextTick(); + assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4, + "control panel buttons should be enabled"); + assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2, + "status bar buttons should be enabled"); + assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1, + "stat buttons should be enabled"); + + form.destroy(); + }); + + QUnit.test('buttons are disabled until button box action is resolved', async function (assert) { + assert.expect(9); + + var def = testUtils.makeTestPromise(); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<button name="post" class="p" string="Confirm" type="object"/>' + + '<button name="some_method" class="s" string="Do it" type="object"/>' + + '</header>' + + '<sheet>' + + '<div name="button_box" class="oe_button_box">' + + '<button class="oe_stat_button">' + + '<field name="bar"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + intercepts: { + execute_action: function (event) { + return def.then(function() { + event.data.on_success(); + }); + } + }, + }); + + assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4, + "control panel buttons should be enabled"); + assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2, + "status bar buttons should be enabled"); + assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1, + "stat buttons should be enabled"); + + await testUtils.dom.click(form.$('.oe_button_box button')); + + // The unresolved promise lets us check the state of the buttons + assert.strictEqual(form.$buttons.find('button:disabled').length, 4, + "control panel buttons should be disabled"); + assert.containsN(form, '.o_form_statusbar button:disabled', 2, + "status bar buttons should be disabled"); + assert.containsOnce(form, '.oe_button_box button:disabled', + "stat buttons should be disabled"); + + def.resolve(); + await testUtils.nextTick(); + assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4, + "control panel buttons should be enabled"); + assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2, + "status bar buttons should be enabled"); + assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1, + "stat buttons should be enabled"); + + form.destroy(); + }); + + QUnit.test('buttons with "confirm" attribute save before calling the method', async function (assert) { + assert.expect(9); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<button name="post" class="p" string="Confirm" type="object" ' + + 'confirm="Very dangerous. U sure?"/>' + + '</header>' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + intercepts: { + execute_action: function (event) { + assert.step('execute_action'); + event.data.on_success(); + }, + }, + }); + + // click on button, and cancel in confirm dialog + await testUtils.dom.click(form.$('.o_statusbar_buttons button')); + assert.ok(form.$('.o_statusbar_buttons button').prop('disabled'), + 'button should be disabled'); + await testUtils.dom.click($('.modal-footer button.btn-secondary')); + assert.ok(!form.$('.o_statusbar_buttons button').prop('disabled'), + 'button should no longer be disabled'); + + assert.verifySteps(['onchange']); + + // click on button, and click on ok in confirm dialog + await testUtils.dom.click(form.$('.o_statusbar_buttons button')); + assert.verifySteps([]); + await testUtils.dom.click($('.modal-footer button.btn-primary')); + assert.verifySteps(['create', 'read', 'execute_action']); + + form.destroy(); + }); + + QUnit.test('buttons are disabled until action is resolved (in dialogs)', async function (assert) { + assert.expect(3); + + var def = testUtils.makeTestPromise(); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="trululu"/>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,form': '<form>' + + '<sheet>' + + '<div name="button_box" class="oe_button_box">' + + '<button class="oe_stat_button">' + + '<field name="bar"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + }, + res_id: 1, + intercepts: { + execute_action: function (event) { + return def.then(function() { + event.data.on_success(); + }); + } + }, + mockRPC: function (route, args) { + if (args.method === 'get_formview_id') { + return Promise.resolve(false); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + }); + + await testUtils.dom.click(form.$('.o_external_button')); + + assert.notOk($('.modal .oe_button_box button').attr('disabled'), + "stat buttons should be enabled"); + + await testUtils.dom.click($('.modal .oe_button_box button')); + + assert.ok($('.modal .oe_button_box button').attr('disabled'), + "stat buttons should be disabled"); + + def.resolve(); + await testUtils.nextTick(); + assert.notOk($('.modal .oe_button_box button').attr('disabled'), + "stat buttons should be enabled"); + + form.destroy(); + }); + + QUnit.test('multiple clicks on save should reload only once', async function (assert) { + assert.expect(4); + + var def = testUtils.makeTestPromise(); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + assert.step(args.method); + if (args.method === "write") { + return def.then(function () { + return result; + }); + } else { + return result; + } + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name="foo"]'), "test"); + await testUtils.form.clickSave(form); + await testUtils.form.clickSave(form); + + def.resolve(); + await testUtils.nextTick(); + assert.verifySteps([ + 'read', // initial read to render the view + 'write', // write on save + 'read' // read on reload + ]); + + form.destroy(); + }); + + QUnit.test('form view is not broken if save operation fails', async function (assert) { + assert.expect(5); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'write' && args.args[1].foo === 'incorrect value') { + return Promise.reject(); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.fields.editInput(form.$('input[name="foo"]'), "incorrect value"); + await testUtils.form.clickSave(form); + + await testUtils.fields.editInput(form.$('input[name="foo"]'), "correct value"); + + await testUtils.form.clickSave(form); + + assert.verifySteps([ + 'read', // initial read to render the view + 'write', // write on save (it fails, does not trigger a read) + 'write', // write on save (it works) + 'read' // read on reload + ]); + + form.destroy(); + }); + + QUnit.test('form view is not broken if save failed in readonly mode on field changed', async function (assert) { + assert.expect(10); + + var failFlag = false; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<header><field name="trululu" widget="statusbar" clickable="true"/></header>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.step('write'); + if (failFlag) { + return Promise.reject(); + } + } else if (args.method === 'read') { + assert.step('read'); + } + return this._super.apply(this, arguments); + }, + }); + + var $selectedState = form.$('.o_statusbar_status button[data-value="4"]'); + assert.ok($selectedState.hasClass('btn-primary') && $selectedState.hasClass('disabled'), + "selected status should be btn-primary and disabled"); + + failFlag = true; + var $clickableState = form.$('.o_statusbar_status button[data-value="1"]'); + await testUtils.dom.click($clickableState); + + var $lastActiveState = form.$('.o_statusbar_status button[data-value="4"]'); + $selectedState = form.$('.o_statusbar_status button.btn-primary'); + assert.strictEqual($selectedState[0], $lastActiveState[0], + "selected status is AAA record after save fail"); + + failFlag = false; + $clickableState = form.$('.o_statusbar_status button[data-value="1"]'); + await testUtils.dom.click($clickableState); + + var $lastClickedState = form.$('.o_statusbar_status button[data-value="1"]'); + $selectedState = form.$('.o_statusbar_status button.btn-primary'); + assert.strictEqual($selectedState[0], $lastClickedState[0], + "last clicked status should be active"); + + assert.verifySteps([ + 'read', + 'write', // fails + 'read', // must reload when saving fails + 'write', // works + 'read', // must reload when saving works + 'read', // fixme: this read should not be necessary + ]); + + form.destroy(); + }); + + QUnit.test('support password attribute', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" password="True"/>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('span[name="foo"]').text(), '***', + "password should be displayed with stars"); + await testUtils.form.clickEdit(form); + assert.strictEqual(form.$('input[name="foo"]').val(), 'yop', + "input value should be the password"); + assert.strictEqual(form.$('input[name="foo"]').prop('type'), 'password', + "input should be of type password"); + form.destroy(); + }); + + QUnit.test('support autocomplete attribute', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name" autocomplete="coucou"/>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + assert.hasAttrValue(form.$('input[name="display_name"]'), 'autocomplete', 'coucou', + "attribute autocomplete should be set"); + form.destroy(); + }); + + QUnit.test('input autocomplete attribute set to none by default', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + assert.hasAttrValue(form.$('input[name="display_name"]'), 'autocomplete', 'off', + "attribute autocomplete should be set to none by default"); + form.destroy(); + }); + + QUnit.test('context is correctly passed after save & new in FormViewDialog', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + res_id: 4, + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="product_ids"/>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'product,false,form': + '<form string="Products">' + + '<sheet>' + + '<group>' + + '<field name="partner_type_id" ' + + 'context="{\'color\': parent.id}"/>' + + '</group>' + + '</sheet>' + + '</form>', + 'product,false,list': '<tree><field name="display_name"/></tree>' + }, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.strictEqual(args.kwargs.context.color, 4, + "should use the correct context"); + } + return this._super.apply(this, arguments); + }, + }); + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.nextTick(); + assert.strictEqual($('.modal').length, 1, + "One FormViewDialog should be opened"); + // set a value on the m2o + await testUtils.fields.many2one.clickOpenDropdown('partner_type_id'); + await testUtils.fields.many2one.clickHighlightedItem('partner_type_id'); + + await testUtils.dom.click($('.modal-footer button:eq(1)')); + await testUtils.nextTick(); + await testUtils.dom.click($('.modal .o_field_many2one input')); + await testUtils.fields.many2one.clickHighlightedItem('partner_type_id'); + await testUtils.dom.click($('.modal-footer button:first')); + await testUtils.nextTick(); + form.destroy(); + }); + + QUnit.test('render domain field widget without model', async function (assert) { + assert.expect(3); + + this.data.partner.fields.model_name = { string: "Model name", type: "char" }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="model_name"/>' + + '<field name="display_name" widget="domain" options="{\'model\': \'model_name\'}"/>' + + '</group>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'search_count') { + assert.strictEqual(args.model, 'test', + "should search_count on test"); + if (!args.kwargs.domain) { + return Promise.reject({message:{ + code: 200, + data: {}, + message: "MockServer._getRecords: given domain has to be an array.", + }, event: $.Event()}); + } + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_field_widget[name="display_name"]').text(), "Select a model to add a filter.", + "should contain an error message saying the model is missing"); + await testUtils.fields.editInput(form.$('input[name="model_name"]'), "test"); + assert.notStrictEqual(form.$('.o_field_widget[name="display_name"]').text(), "Select a model to add a filter.", + "should not contain an error message anymore"); + form.destroy(); + }); + + QUnit.test('readonly fields are not sent when saving', async function (assert) { + assert.expect(6); + + // define an onchange on display_name to check that the value of readonly + // fields is correctly sent for onchanges + this.data.partner.onchanges = { + display_name: function () {}, + p: function () {}, + }; + var checkOnchange = false; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '<field name="foo" attrs="{\'readonly\': [[\'display_name\', \'=\', \'readonly\']]}"/>' + + '</form>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (checkOnchange && args.method === 'onchange') { + if (args.args[2] === 'display_name') { // onchange on field display_name + assert.strictEqual(args.args[1].foo, 'foo value', + "readonly fields value should be sent for onchanges"); + } else { // onchange on field p + assert.deepEqual(args.args[1].p, [ + [0, args.args[1].p[0][1], {display_name: 'readonly', foo: 'foo value'}] + ], "readonly fields value should be sent for onchanges"); + } + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], { + p: [[0, args.args[0].p[0][1], {display_name: 'readonly'}]] + }, "should not have sent the value of the readonly field"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.nextTick(); + assert.strictEqual($('.modal input.o_field_widget[name=foo]').length, 1, + 'foo should be editable'); + checkOnchange = true; + await testUtils.fields.editInput($('.modal .o_field_widget[name=foo]'), 'foo value'); + await testUtils.fields.editInput($('.modal .o_field_widget[name=display_name]'), 'readonly'); + assert.strictEqual($('.modal span.o_field_widget[name=foo]').length, 1, + 'foo should be readonly'); + await testUtils.dom.clickFirst($('.modal-footer .btn-primary')); + await testUtils.nextTick(); + checkOnchange = false; + + await testUtils.dom.click(form.$('.o_data_row')); + assert.strictEqual($('.modal .o_field_widget[name=foo]').text(), 'foo value', + "the edited value should have been kept"); + await testUtils.dom.clickFirst($('.modal-footer .btn-primary')); + await testUtils.nextTick(); + + await testUtils.form.clickSave(form); // save the record + form.destroy(); + }); + + QUnit.test('id is False in evalContext for new records', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="id"/>' + + '<field name="foo" attrs="{\'readonly\': [[\'id\', \'=\', False]]}"/>' + + '</form>', + }); + + assert.hasClass(form.$('.o_field_widget[name=foo]'),'o_readonly_modifier', + "foo should be readonly in 'Create' mode"); + + await testUtils.form.clickSave(form); + await testUtils.form.clickEdit(form); + + assert.doesNotHaveClass(form.$('.o_field_widget[name=foo]'), 'o_readonly_modifier', + "foo should not be readonly anymore"); + + form.destroy(); + }); + + QUnit.test('delete a duplicated record', async function (assert) { + assert.expect(5); + + var newRecordID; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>', + res_id: 1, + viewOptions: {hasActionMenus: true}, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'copy') { + return result.then(function (id) { + newRecordID = id; + return id; + }); + } + if (args.method === 'unlink') { + assert.deepEqual(args.args[0], [newRecordID], + "should delete the newly created record"); + } + return result; + }, + }); + + // duplicate record 1 + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Duplicate"); + + assert.containsOnce(form, '.o_form_editable', + "form should be in edit mode"); + assert.strictEqual(form.$('.o_field_widget').val(), 'first record (copy)', + "duplicated record should have correct name"); + await testUtils.form.clickSave(form); // save duplicated record + + // delete duplicated record + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Delete"); + + assert.strictEqual($('.modal').length, 1, "should have opened a confirm dialog"); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + assert.strictEqual(form.$('.o_field_widget').text(), 'first record', + "should have come back to previous record"); + + form.destroy(); + }); + + QUnit.test('display tooltips for buttons', async function (assert) { + assert.expect(2); + + var initialDebugMode = odoo.debug; + odoo.debug = true; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<button name="some_method" class="oe_highlight" string="Button" type="object"/>' + + '</header>' + + '<button name="other_method" class="oe_highlight" string="Button2" type="object"/>' + + '</form>', + }); + + var $button = form.$('.o_form_statusbar button'); + $button.tooltip('show', false); + $button.trigger($.Event('mouseenter')); + + assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1, + "should have rendered a tooltip"); + $button.trigger($.Event('mouseleave')); + + var $secondButton = form.$('button[name="other_method"]'); + $secondButton.tooltip('show', false); + $secondButton.trigger($.Event('mouseenter')); + + assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1, + "should have rendered a tooltip"); + $secondButton.trigger($.Event('mouseleave')); + + odoo.debug = initialDebugMode; + form.destroy(); + }); + + QUnit.test('reload event is handled only once', async function (assert) { + // In this test, several form controllers are nested (two of them are + // opened in dialogs). When the users clicks on save in the last + // opened dialog, a 'reload' event is triggered up to reload the (direct) + // parent view. If this event isn't stopPropagated by the first controller + // catching it, it will crash when the other one will try to handle it, + // as this one doesn't know at all the dataPointID to reload. + assert.expect(11); + + var arch = '<form>' + + '<field name="display_name"/>' + + '<field name="trululu"/>' + + '</form>'; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: arch, + archs: { + 'partner,false,form': arch, + }, + res_id: 2, + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'get_formview_id') { + return Promise.resolve(false); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + }); + + await testUtils.dom.click(form.$('.o_external_button')); + await testUtils.dom.click($('.modal .o_external_button')); + + await testUtils.fields.editInput($('.modal:nth(1) .o_field_widget[name=display_name]'), 'new name'); + await testUtils.dom.click($('.modal:nth(1) footer .btn-primary').first()); + + assert.strictEqual($('.modal .o_field_widget[name=trululu] input').val(), 'new name', + "record should have been reloaded"); + assert.verifySteps([ + "read", // main record + "get_formview_id", // id of first form view opened in a dialog + "load_views", // arch of first form view opened in a dialog + "read", // first dialog + "get_formview_id", // id of second form view opened in a dialog + "load_views", // arch of second form view opened in a dialog + "read", // second dialog + "write", // save second dialog + "read", // reload first dialog + ]); + + form.destroy(); + }); + + QUnit.test('process the context for inline subview', async function (assert) { + assert.expect(1); + + this.data.partner.records[0].p = [2]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="bar" invisible="context.get(\'hide_bar\', False)"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + viewOptions: { + context: {hide_bar: true}, + }, + }); + assert.containsOnce(form, '.o_list_view thead tr th', + "there should be only one column"); + form.destroy(); + }); + + QUnit.test('process the context for subview not inline', async function (assert) { + assert.expect(1); + + this.data.partner.records[0].p = [2]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p"/>' + + '</form>', + archs: { + "partner,false,list": '<tree>' + + '<field name="foo"/>' + + '<field name="bar" invisible="context.get(\'hide_bar\', False)"/>' + + '</tree>', + }, + res_id: 1, + viewOptions: { + context: {hide_bar: true}, + }, + }); + assert.containsOnce(form, '.o_list_view thead tr th', + "there should be only one column"); + form.destroy(); + }); + + QUnit.test('can toggle column in x2many in sub form view', async function (assert) { + assert.expect(2); + + this.data.partner.records[2].p = [1,2]; + this.data.partner.fields.foo.sortable = true; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="trululu"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/get_formview_id') { + return Promise.resolve(false); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,false,form': '<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + }, + viewOptions: {mode: 'edit'}, + }); + await testUtils.dom.click(form.$('.o_external_button')); + assert.strictEqual($('.modal-body .o_form_view .o_list_view .o_data_cell').text(), "yopblip", + "table has some initial order"); + + await testUtils.dom.click($('.modal-body .o_form_view .o_list_view th.o_column_sortable')); + assert.strictEqual($('.modal-body .o_form_view .o_list_view .o_data_cell').text(), "blipyop", + "table is now sorted"); + form.destroy(); + }); + + QUnit.test('rainbowman attributes correctly passed on button click', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<button name="action_won" string="Won" type="object" effect="{\'message\': \'Congrats!\'}"/>' + + '</header>' + + '</form>', + intercepts: { + execute_action: function (event) { + var effectDescription = pyUtils.py_eval(event.data.action_data.effect); + assert.deepEqual(effectDescription, {message: 'Congrats!'}, "should have correct effect description"); + } + }, + }); + + await testUtils.dom.click(form.$('.o_form_statusbar .btn-secondary')); + form.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 form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '<widget name="test"/>' + + '</form>', + }); + + assert.strictEqual(form.$('.o_widget').text(), '{"foo":"My little Foo Value","bar":false}', + "widget should have been instantiated"); + + form.destroy(); + delete widgetRegistry.map.test; + }); + + QUnit.test('attach document widget calls action with attachment ids', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'my_action') { + assert.deepEqual(args.kwargs.attachment_ids, [5, 2]); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + arch: '<form>' + + '<widget name="attach_document" action="my_action"/>' + + '</form>', + }); + + var onFileLoadedEventName = form.$('.o_form_binary_form').attr('target'); + // trigger _onFileLoaded function + $(window).trigger(onFileLoadedEventName, [{id: 5}, {id:2}]); + + form.destroy(); + }); + + QUnit.test('support header button as widgets on form statusbar', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<header>' + + '<widget name="attach_document" string="Attach document"/>' + + '</header>' + + '</form>', + }); + + assert.containsOnce(form, 'button.o_attachment_button', + "should have 1 attach_document widget in the statusbar"); + assert.strictEqual(form.$('span.o_attach_document').text().trim(), 'Attach document', + "widget should have been instantiated"); + + form.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(this.data.foo + "!"); + }, + updateState: function (dataPoint) { + this.$el.text(dataPoint.data.foo + "!"); + }, + }); + widgetRegistry.add('test', MyWidget); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<widget name="test"/>' + + '</form>', + }); + + await testUtils.fields.editInput(form.$('input[name="foo"]'), "I am alive"); + assert.strictEqual(form.$('.o_widget').text(), 'I am alive!', + "widget should have been updated"); + + form.destroy(); + delete widgetRegistry.map.test; + }); + + QUnit.test('bounce edit button in readonly mode', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<div class="oe_title">' + + '<field name="display_name"/>' + + '</div>' + + '</form>', + res_id: 1, + }); + + // in readonly + await testUtils.dom.click(form.$('[name="display_name"]')); + assert.hasClass(form.$('.o_form_button_edit'), 'o_catch_attention'); + + // in edit + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('[name="display_name"]')); + // await testUtils.nextTick(); + assert.containsNone(form, 'button.o_catch_attention:visible'); + + form.destroy(); + }); + + QUnit.test('proper stringification in debug mode tooltip', async function (assert) { + assert.expect(6); + + var initialDebugMode = odoo.debug; + odoo.debug = true; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="product_id" context="{\'lang\': \'en_US\'}" ' + + 'attrs=\'{"invisible": [["product_id", "=", 33]]}\' ' + + 'widget="many2one" />' + + '</sheet>' + + '</form>', + }); + + var $field = form.$('[name="product_id"]'); + $field.tooltip('show', true); + $field.trigger($.Event('mouseenter')); + assert.strictEqual($('.oe_tooltip_technical>li[data-item="context"]').length, + 1, 'context should be present for this field'); + assert.strictEqual($('.oe_tooltip_technical>li[data-item="context"]')[0].lastChild.wholeText.trim(), + "{'lang': 'en_US'}", "context should be properly stringified"); + + assert.strictEqual($('.oe_tooltip_technical>li[data-item="modifiers"]').length, + 1, 'modifiers should be present for this field'); + assert.strictEqual($('.oe_tooltip_technical>li[data-item="modifiers"]')[0].lastChild.wholeText.trim(), + '{"invisible":[["product_id","=",33]]}', "modifiers should be properly stringified"); + + assert.strictEqual($('.oe_tooltip_technical>li[data-item="widget"]').length, + 1, 'widget should be present for this field'); + assert.strictEqual($('.oe_tooltip_technical>li[data-item="widget"]')[0].lastChild.wholeText.trim(), + 'Many2one (many2one)', "widget description should be correct"); + + odoo.debug = initialDebugMode; + form.destroy(); + }); + + QUnit.test('autoresize of text fields is done when switching to edit mode', async function (assert) { + assert.expect(4); + + this.data.partner.fields.text_field = { string: 'Text field', type: 'text' }; + this.data.partner.fields.text_field.default = "some\n\nmulti\n\nline\n\ntext\n"; + this.data.partner.records[0].text_field = "a\nb\nc\nd\ne\nf"; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="text_field"/>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + // switch to edit mode to ensure that autoresize is correctly done + await testUtils.form.clickEdit(form); + var height = form.$('.o_field_widget[name=text_field]').height(); + // focus the field to manually trigger autoresize + form.$('.o_field_widget[name=text_field]').trigger('focus'); + assert.strictEqual(form.$('.o_field_widget[name=text_field]').height(), height, + "autoresize should have been done automatically at rendering"); + // next assert simply tries to ensure that the textarea isn't stucked to + // its minimal size, even after being focused + assert.ok(height > 80, "textarea should have an height of at least 80px"); + + // save and create a new record to ensure that autoresize is correctly done + await testUtils.form.clickSave(form); + await testUtils.form.clickCreate(form); + height = form.$('.o_field_widget[name=text_field]').height(); + // focus the field to manually trigger autoresize + form.$('.o_field_widget[name=text_field]').trigger('focus'); + assert.strictEqual(form.$('.o_field_widget[name=text_field]').height(), height, + "autoresize should have been done automatically at rendering"); + assert.ok(height > 80, "textarea should have an height of at least 80px"); + + form.destroy(); + }); + + QUnit.test('autoresize of text fields is done on notebook page show', async function (assert) { + assert.expect(5); + + this.data.partner.fields.text_field = { string: 'Text field', type: 'text' }; + this.data.partner.fields.text_field.default = "some\n\nmulti\n\nline\n\ntext\n"; + this.data.partner.records[0].text_field = "a\nb\nc\nd\ne\nf"; + this.data.partner.fields.text_field_empty = { string: 'Text field', type: 'text' }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="First Page">' + + '<field name="foo"/>' + + '</page>' + + '<page string="Second Page">' + + '<field name="text_field"/>' + + '</page>' + + '<page string="Third Page">' + + '<field name="text_field_empty"/>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + assert.hasClass(form.$('.o_notebook .nav .nav-link:first()'), 'active'); + + await testUtils.dom.click(form.$('.o_notebook .nav .nav-link:nth(1)')); + assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active'); + + var height = form.$('.o_field_widget[name=text_field]').height(); + assert.ok(height > 80, "textarea should have an height of at least 80px"); + + await testUtils.dom.click(form.$('.o_notebook .nav .nav-link:nth(2)')); + assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(2)'), 'active'); + + var height = form.$('.o_field_widget[name=text_field_empty]').css('height'); + assert.strictEqual(height, '50px', "empty textarea should have height of 50px"); + + form.destroy(); + }); + + QUnit.test('check if the view destroys all widgets and instances', async function (assert) { + assert.expect(2); + + var instanceNumber = 0; + await 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: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<field name="qux"/>' + + '<field name="trululu"/>' + + '<field name="timmy"/>' + + '<field name="product_id"/>' + + '<field name="priority"/>' + + '<field name="state"/>' + + '<field name="date"/>' + + '<field name="datetime"/>' + + '<field name="product_ids"/>' + + '<field name="p">' + + '<tree default_order="foo desc">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,form': + '<form string="Partner">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + "partner_type,false,list": '<tree><field name="name"/></tree>', + 'product,false,list': '<tree><field name="display_name"/></tree>', + + }, + res_id: 1, + }; + + var form = await createView(params); + assert.ok(instanceNumber > 0); + + form.destroy(); + assert.strictEqual(instanceNumber, 0); + + await testUtils.mock.unpatch(mixins.ParentedMixin); + }); + + QUnit.test('do not change pager when discarding current record', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 2, + }); + + assert.strictEqual(cpHelpers.getPagerValue(form), "2", + 'pager should indicate that we are on second record'); + assert.strictEqual(cpHelpers.getPagerSize(form), "2", + 'pager should indicate that we are on second record'); + + await testUtils.form.clickEdit(form); + await testUtils.form.clickDiscard(form); + + assert.strictEqual(cpHelpers.getPagerValue(form), "2", + 'pager value should not have changed'); + assert.strictEqual(cpHelpers.getPagerSize(form), "2", + 'pager limit should not have changed'); + + form.destroy(); + }); + + QUnit.test('Form view from ordered, grouped list view correct context', async function (assert) { + assert.expect(10); + this.data.partner.records[0].timmy = [12]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '<field name="timmy"/>' + + '</form>', + archs: { + 'partner_type,false,list': + '<tree>' + + '<field name="name"/>' + + '</tree>', + }, + viewOptions: { + // Simulates coming from a list view with a groupby and filter + context: { + orderedBy: [{name: 'foo', asc:true}], + group_by: ['foo'], + } + }, + res_id: 1, + mockRPC: function (route, args) { + assert.step(args.model + ":" + args.method); + if (args.method === 'read') { + assert.ok(args.kwargs.context, 'context is present'); + assert.notOk('orderedBy' in args.kwargs.context, + 'orderedBy not in context'); + assert.notOk('group_by' in args.kwargs.context, + 'group_by not in context'); + } + return this._super.apply(this, arguments); + } + }); + + assert.verifySteps(['partner_type:load_views', 'partner:read', 'partner_type:read']); + + form.destroy(); + }); + + QUnit.test('edition in form view on a "noCache" model', async function (assert) { + assert.expect(5); + + await testUtils.mock.patch(BasicModel, { + noCacheModels: BasicModel.prototype.noCacheModels.concat(['partner']), + }); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="display_name"/>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.step('write'); + } + return this._super.apply(this, arguments); + }, + }); + core.bus.on('clear_cache', form, assert.step.bind(assert, 'clear_cache')); + + await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'new value'); + await testUtils.form.clickSave(form); + + assert.verifySteps(['write', 'clear_cache']); + + form.destroy(); + await testUtils.mock.unpatch(BasicModel); + + assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy + }); + + QUnit.test('creation in form view on a "noCache" model', async function (assert) { + assert.expect(5); + + await testUtils.mock.patch(BasicModel, { + noCacheModels: BasicModel.prototype.noCacheModels.concat(['partner']), + }); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="display_name"/>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.step('create'); + } + return this._super.apply(this, arguments); + }, + }); + core.bus.on('clear_cache', form, assert.step.bind(assert, 'clear_cache')); + + await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'value'); + await testUtils.form.clickSave(form); + + assert.verifySteps(['create', 'clear_cache']); + + form.destroy(); + await testUtils.mock.unpatch(BasicModel); + + assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy + }); + + QUnit.test('deletion in form view on a "noCache" model', async function (assert) { + assert.expect(5); + + await testUtils.mock.patch(BasicModel, { + noCacheModels: BasicModel.prototype.noCacheModels.concat(['partner']), + }); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="display_name"/>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'unlink') { + assert.step('unlink'); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + hasActionMenus: true, + }, + }); + core.bus.on('clear_cache', form, assert.step.bind(assert, 'clear_cache')); + + await cpHelpers.toggleActionMenu(form); + await cpHelpers.toggleMenuItem(form, "Delete"); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + assert.verifySteps(['unlink', 'clear_cache']); + + form.destroy(); + await testUtils.mock.unpatch(BasicModel); + + assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy + }); + + QUnit.test('reload currencies when writing on records of model res.currency', async function (assert) { + assert.expect(5); + + this.data['res.currency'] = { + fields: {}, + records: [{id: 1, display_name: "some currency"}], + }; + + var form = await createView({ + View: FormView, + model: 'res.currency', + data: this.data, + arch: '<form><field name="display_name"/></form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + session: { + reloadCurrencies: function () { + assert.step('reload currencies'); + }, + }, + }); + + await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'new value'); + await testUtils.form.clickSave(form); + + assert.verifySteps([ + 'read', + 'write', + 'reload currencies', + 'read', + ]); + + form.destroy(); + }); + + QUnit.test('keep editing after call_button fail', async function (assert) { + assert.expect(4); + + var values; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form>' + + '<button name="post" class="p" string="Raise Error" type="object"/>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + intercepts: { + execute_action: function (ev) { + assert.ok(true, 'the action is correctly executed'); + ev.data.on_fail(); + }, + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1].p[0][2], values); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + }); + + // add a row and partially fill it + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.fields.editInput(form.$('input[name=display_name]'), 'abc'); + + // click button which will trigger_up 'execute_action' (this will save) + values = { + display_name: 'abc', + product_id: false, + }; + await testUtils.dom.click(form.$('button.p')); + // edit the new row again and set a many2one value + await testUtils.dom.clickLast(form.$('.o_form_view .o_field_one2many .o_data_row .o_data_cell')); + await testUtils.nextTick(); + await testUtils.fields.many2one.clickOpenDropdown('product_id'); + await testUtils.fields.many2one.clickHighlightedItem('product_id'); + + assert.strictEqual(form.$('.o_field_many2one input').val(), 'xphone', + "value of the m2o should have been correctly updated"); + + values = { + product_id: 37, + }; + await testUtils.form.clickSave(form); + + form.destroy(); + }); + + QUnit.test('asynchronous rendering of a widget tag', async function (assert) { + assert.expect(1); + + var def1 = testUtils.makeTestPromise(); + + var MyWidget = Widget.extend({ + willStart: function() { + return def1; + }, + }); + + widgetRegistry.add('test', MyWidget); + + createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<widget name="test"/>' + + '</form>', + }).then(function(form) { + assert.containsOnce(form, 'div.o_widget', + "there should be a div with widget class"); + form.destroy(); + delete widgetRegistry.map.test; + }); + + def1.resolve(); + await testUtils.nextTick(); + }); + + QUnit.test('no deadlock when saving with uncommitted changes', async function (assert) { + // Before saving a record, all field widgets are asked to commit their changes (new values + // that they wouldn't have sent to the model yet). This test is added alongside a bug fix + // ensuring that we don't end up in a deadlock when a widget actually has some changes to + // commit at that moment. By chance, this situation isn't reached when the user clicks on + // 'Save' (which is the natural way to save a record), because by clicking outside the + // widget, the 'change' event (this is mainly for InputFields) is triggered, and the widget + // notifies the model of its new value on its own initiative, before being requested to. + // In this test, we try to reproduce the deadlock situation by forcing the field widget to + // commit changes before the save. We thus manually call 'saveRecord', instead of clicking + // on 'Save'. + assert.expect(6); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="foo"/></form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + // we set a fieldDebounce to precisely mock the behavior of the webclient: changes are + // not sent to the model at keystrokes, but when the input is left + fieldDebounce: 5000, + }); + + await testUtils.fields.editInput(form.$('input[name=foo]'), 'some foo value'); + // manually save the record, to prevent the field widget to notify the model of its new + // value before being requested to + form.saveRecord(); + + await testUtils.nextTick(); + + assert.containsOnce(form, '.o_form_readonly', "form view should be in readonly"); + assert.strictEqual(form.$('.o_form_view').text().trim(), 'some foo value', + "foo field should have correct value"); + assert.verifySteps(['onchange', 'create', 'read']); + + form.destroy(); + }); + + QUnit.test('save record with onchange on one2many with required field', async function (assert) { + // in this test, we have a one2many with a required field, whose value is + // set by an onchange on another field ; we manually set the value of that + // first field, and directly click on Save (before the onchange RPC returns + // and sets the value of the required field) + assert.expect(6); + + this.data.partner.fields.foo.default = undefined; + this.data.partner.onchanges = { + display_name: function (obj) { + obj.foo = obj.display_name ? 'foo value' : undefined; + }, + }; + + var onchangeDef; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '<field name="foo" required="1"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + return Promise.resolve(onchangeDef).then(_.constant(result)); + } + if (args.method === 'create') { + assert.step('create'); + assert.strictEqual(args.args[0].p[0][2].foo, 'foo value', + "should have wait for the onchange to return before saving"); + } + return result; + }, + }); + + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + + assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), '', + "display_name should be the empty string by default"); + assert.strictEqual(form.$('.o_field_widget[name=foo]').val(), '', + "foo should be the empty string by default"); + + onchangeDef = testUtils.makeTestPromise(); // delay the onchange + + await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'some value'); + + await testUtils.form.clickSave(form); + + assert.step('resolve'); + onchangeDef.resolve(); + await testUtils.nextTick(); + + assert.verifySteps(['resolve', 'create']); + + form.destroy(); + }); + + QUnit.test('call canBeRemoved while saving', async function (assert) { + assert.expect(10); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.display_name = obj.foo === 'trigger onchange' ? 'changed' : 'default'; + }, + }; + + var onchangeDef; + var createDef = testUtils.makeTestPromise(); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="display_name"/><field name="foo"/></form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + return Promise.resolve(onchangeDef).then(_.constant(result)); + } + if (args.method === 'create') { + return Promise.resolve(createDef).then(_.constant(result)); + } + return result; + }, + }); + + // edit foo to trigger a delayed onchange + onchangeDef = testUtils.makeTestPromise(); + await testUtils.fields.editInput(form.$('.o_field_widget[name=foo]'), 'trigger onchange'); + + assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'default'); + + // save (will wait for the onchange to return), and will be delayed as well + await testUtils.dom.click(form.$buttons.find('.o_form_button_save')); + + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'default'); + + // simulate a click on the breadcrumbs to leave the form view + form.canBeRemoved(); + await testUtils.nextTick(); + + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'default'); + + // unlock the onchange + onchangeDef.resolve(); + await testUtils.nextTick(); + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'changed'); + + // unlock the create + createDef.resolve(); + await testUtils.nextTick(); + + assert.hasClass(form.$('.o_form_view'), 'o_form_readonly'); + assert.strictEqual(form.$('.o_field_widget[name=display_name]').text(), 'changed'); + assert.containsNone(document.body, '.modal', + "should not display the 'Changes will be discarded' dialog"); + + form.destroy(); + }); + + QUnit.test('call canBeRemoved twice', async function (assert) { + assert.expect(4); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="display_name"/><field name="foo"/></form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.containsOnce(form, '.o_form_editable'); + await testUtils.fields.editInput(form.$('.o_field_widget[name=foo]'), 'some value'); + + form.canBeRemoved(); + await testUtils.nextTick(); + assert.containsOnce(document.body, '.modal'); + + form.canBeRemoved(); + await testUtils.nextTick(); + assert.containsOnce(document.body, '.modal'); + + await testUtils.dom.click($('.modal .modal-footer .btn-secondary')); + + assert.containsNone(document.body, '.modal'); + + form.destroy(); + }); + + QUnit.test('domain returned by onchange is cleared on discard', async function (assert) { + assert.expect(4); + + this.data.partner.onchanges = { + foo: function () {}, + }; + + var domain = ['id', '=', 1]; + var expectedDomain = domain; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="foo"/><field name="trululu"/></form>', + mockRPC: function (route, args) { + if (args.method === 'onchange' && args.args[0][0] === 1) { + // onchange returns a domain only on record 1 + return Promise.resolve({ + domain: { + trululu: domain, + }, + }); + } + if (args.method === 'name_search') { + assert.deepEqual(args.kwargs.args, expectedDomain); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + ids: [1, 2], + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "should be on record 1"); + + // change foo to trigger the onchange + await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); + + // open many2one dropdown to check if the domain is applied + await testUtils.fields.many2one.clickOpenDropdown('trululu'); + + // switch to another record (should ask to discard changes, and reset the domain) + await cpHelpers.pagerNext(form); + + // discard changes by clicking on confirm in the dialog + await testUtils.dom.click($('.modal .modal-footer .btn-primary:first')); + + assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "should be on record 2"); + + // open many2one dropdown to check if the domain is applied + expectedDomain = []; + await testUtils.fields.many2one.clickOpenDropdown('trululu'); + + form.destroy(); + }); + + QUnit.test('discard after a failed save', async function (assert) { + assert.expect(2); + + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,form': '<form>' + + '<field name="date" required="true"/>' + + '<field name="foo" required="true"/>' + + '</form>', + 'partner,false,kanban': '<kanban><templates><t t-name="kanban-box">' + + '</t></templates></kanban>', + 'partner,false,search': '<search></search>', + }, + actions: this.actions, + }); + + await actionManager.doAction(1); + + await testUtils.dom.click('.o_control_panel .o-kanban-button-new'); + + //cannot save because there is a required field + await testUtils.dom.click('.o_control_panel .o_form_button_save'); + await testUtils.dom.click('.o_control_panel .o_form_button_cancel'); + + assert.containsNone(actionManager, '.o_form_view'); + assert.containsOnce(actionManager, '.o_kanban_view'); + + actionManager.destroy(); + }); + + QUnit.test("one2many create record dialog shouldn't have a 'remove' button", async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="p">' + + '<kanban>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<field name="foo"/>' + + '</t>' + + '</templates>' + + '</kanban>' + + '<form>' + + '<field name="foo"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickCreate(form); + await testUtils.dom.click(form.$('.o-kanban-button-new')); + + assert.containsOnce(document.body, '.modal'); + assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 0, + "shouldn't have a 'remove' button on new records"); + + form.destroy(); + }); + + QUnit.test('edit a record in readonly and switch to edit before it is actually saved', async function (assert) { + assert.expect(3); + + const prom = testUtils.makeTestPromise(); + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: `<form> + <field name="foo"/> + <field name="bar" widget="toggle_button"/> + </form>`, + mockRPC: function (route, args) { + const result = this._super.apply(this, arguments); + if (args.method === 'write') { // delay the write RPC + assert.deepEqual(args.args[1], {bar: false}); + return prom.then(_.constant(result)); + } + return result; + }, + res_id: 1, + }); + + // edit the record (in readonly) with toogle_button widget (and delay the write RPC) + await testUtils.dom.click(form.$('.o_field_widget[name=bar]')); + + // switch to edit mode + await testUtils.form.clickEdit(form); + + assert.hasClass(form.$('.o_form_view'), 'o_form_readonly'); // should wait for the RPC to return + + // make write RPC return + prom.resolve(); + await testUtils.nextTick(); + + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + + form.destroy(); + }); + + QUnit.test('"bare" buttons in template should not trigger button click', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<button string="Save" class="btn-primary" special="save"/>' + + '<button class="mybutton">westvleteren</button>' + + '</form>', + res_id: 2, + intercepts: { + execute_action: function () { + assert.step('execute_action'); + }, + }, + }); + await testUtils.dom.click(form.$('.o_form_view button.btn-primary')); + assert.verifySteps(['execute_action']); + await testUtils.dom.click(form.$('.o_form_view button.mybutton')); + assert.verifySteps([]); + form.destroy(); + }); + + QUnit.test('form view with inline tree view with optional fields and local storage mock', async function (assert) { + assert.expect(12); + + var Storage = RamStorage.extend({ + getItem: function (key) { + assert.step('getItem ' + key); + return 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 form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="qux"/>' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="bar" optional="hide"/>' + + '</tree>' + + '</field>' + + '</form>', + services: { + local_storage: RamStorageService, + }, + view_id: 27, + }); + + var localStorageKey = 'optional_fields,partner,form,27,p,list,undefined,bar,foo'; + + assert.verifySteps(['getItem ' + localStorageKey]); + + assert.containsN(form, 'th', 2, + "should have 2 th, 1 for selector, 1 for foo column"); + + assert.ok(form.$('th:contains(Foo)').is(':visible'), + "should have a visible foo field"); + + assert.notOk(form.$('th:contains(Bar)').is(':visible'), + "should not have a visible bar field"); + + // optional fields + await testUtils.dom.click(form.$('table .o_optional_columns_dropdown_toggle')); + assert.containsN(form, 'div.o_optional_columns div.dropdown-item', 1, + "dropdown have 1 optional field"); + + // enable optional field + await testUtils.dom.click(form.$('div.o_optional_columns div.dropdown-item input')); + + assert.verifySteps([ + 'setItem ' + localStorageKey + ' to ["bar"]', + 'getItem ' + localStorageKey, + ]); + + assert.containsN(form, 'th', 3, + "should have 3 th, 1 for selector, 2 for columns"); + + assert.ok(form.$('th:contains(Foo)').is(':visible'), + "should have a visible foo field"); + + assert.ok(form.$('th:contains(Bar)').is(':visible'), + "should have a visible bar field"); + + form.destroy(); + }); + + QUnit.test('form view with tree_view_ref with optional fields and local storage mock', async function (assert) { + assert.expect(12); + + var Storage = RamStorage.extend({ + getItem: function (key) { + assert.step('getItem ' + key); + return 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 form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="qux"/>' + + '<field name="p" context="{\'tree_view_ref\': \'34\'}"/>' + + '</form>', + archs: { + "partner,nope_not_this_one,list": '<tree>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</tree>', + "partner,34,list": '<tree>' + + '<field name="foo" optional="hide"/>' + + '<field name="bar"/>' + + '</tree>', + }, + services: { + local_storage: RamStorageService, + }, + view_id: 27, + }); + + var localStorageKey = 'optional_fields,partner,form,27,p,list,34,bar,foo'; + + assert.verifySteps(['getItem ' + localStorageKey]); + + assert.containsN(form, 'th', 2, + "should have 2 th, 1 for selector, 1 for foo column"); + + assert.notOk(form.$('th:contains(Foo)').is(':visible'), + "should have a visible foo field"); + + assert.ok(form.$('th:contains(Bar)').is(':visible'), + "should not have a visible bar field"); + + // optional fields + await testUtils.dom.click(form.$('table .o_optional_columns_dropdown_toggle')); + assert.containsN(form, 'div.o_optional_columns div.dropdown-item', 1, + "dropdown have 1 optional field"); + + // enable optional field + await testUtils.dom.click(form.$('div.o_optional_columns div.dropdown-item input')); + + assert.verifySteps([ + 'setItem ' + localStorageKey + ' to ["foo"]', + 'getItem ' + localStorageKey, + ]); + + assert.containsN(form, 'th', 3, + "should have 3 th, 1 for selector, 2 for columns"); + + assert.ok(form.$('th:contains(Foo)').is(':visible'), + "should have a visible foo field"); + + assert.ok(form.$('th:contains(Bar)').is(':visible'), + "should have a visible bar field"); + + form.destroy(); + }); + + QUnit.test('using tab in an empty required string field should not move to the next field', async function(assert) { + assert.expect(3); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="display_name" required="1" />' + + '<field name="foo" />' + + '</group>' + + '</sheet>' + + '</form>', + }); + + await testUtils.dom.click(form.$('input[name=display_name]')); + assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement, + "display_name should be focused"); + form.$('input[name="display_name"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement, + "display_name should still be focused because it is empty and required"); + assert.hasClass(form.$('input[name="display_name"]'), 'o_field_invalid', + "display_name should have the o_field_invalid class"); + form.destroy(); + }); + + QUnit.test('using tab in an empty required date field should not move to the next field', async function(assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="date" required="1" />' + + '<field name="foo" />' + + '</group>' + + '</sheet>' + + '</form>', + }); + + await testUtils.dom.click(form.$('input[name=date]')); + assert.strictEqual(form.$('input[name="date"]')[0], document.activeElement, + "display_name should be focused"); + form.$('input[name="date"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$('input[name="date"]')[0], document.activeElement, + "date should still be focused because it is empty and required"); + + form.destroy(); + }); + + QUnit.test('Edit button get the focus when pressing TAB from form', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<div class="oe_title">' + + '<field name="display_name"/>' + + '</div>' + + '</form>', + res_id: 1, + }); + + // in edit + await testUtils.form.clickEdit(form); + form.$('input[name="display_name"]').focus().trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$buttons.find('.btn-primary:visible')[0], document.activeElement, + "the first primary button (save) should be focused"); + form.destroy(); + }); + + QUnit.test('In Edition mode, after navigating to the last field, the default button when pressing TAB is SAVE', async function (assert) { + assert.expect(1); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="state" invisible="1"/>' + + '<header>' + + '<button name="post" class="btn-primary firstButton" string="Confirm" type="object"/>' + + '<button name="post" class="btn-primary secondButton" string="Confirm2" type="object"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<div class="oe_title">' + + '<field name="display_name"/>' + + '</div>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('input[name="display_name"]').focus().trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$buttons.find('.o_form_button_save:visible')[0], document.activeElement, + "the save should be focused"); + form.destroy(); + }); + + QUnit.test('In READ mode, the default button with focus is the first primary button of the form', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="state" invisible="1"/>' + + '<header>' + + '<button name="post" class="btn-primary firstButton" string="Confirm" type="object"/>' + + '<button name="post" class="btn-primary secondButton" string="Confirm2" type="object"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<div class="oe_title">' + + '<field name="display_name"/>' + + '</div>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + assert.strictEqual(form.$('button.firstButton')[0], document.activeElement, + "by default the focus in edit mode should go to the first primary button of the form (not edit)"); + form.destroy(); + }); + + QUnit.test('In READ mode, the default button when pressing TAB is EDIT when there is no primary button on the form', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="state" invisible="1"/>' + + '<header>' + + '<button name="post" class="not-primary" string="Confirm" type="object"/>' + + '<button name="post" class="not-primary" string="Confirm2" type="object"/>' + + '</header>' + + '<sheet>' + + '<group>' + + '<div class="oe_title">' + + '<field name="display_name"/>' + + '</div>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + assert.strictEqual(form.$buttons.find('.o_form_button_edit')[0],document.activeElement, + "in read mode, when there are no primary buttons on the form, the default button with the focus should be edit"); + form.destroy(); + }); + + QUnit.test('In Edition mode, when an attribute is dynamically required (and not required), TAB should navigate to the next field', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 5, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('input[name="foo"]').focus(); + $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + + assert.strictEqual(form.$('div[name="bar"]>input')[0], document.activeElement, "foo is not required, so hitting TAB on foo should have moved the focus to BAR"); + form.destroy(); + }); + + QUnit.test('In Edition mode, when an attribute is dynamically required, TAB should stop on the field if it is required', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 5, + viewOptions: { + mode: 'edit', + }, + }); + + await testUtils.dom.click(form.$('div[name="bar"]>input')); + form.$('input[name="foo"]').focus(); + $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + + assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, "foo is required, so hitting TAB on foo should keep the focus on foo"); + form.destroy(); + }); + + QUnit.test('display tooltips for save and discard buttons', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" />'+ + '</form>', + }); + + form.$buttons.find('.o_form_buttons_edit').tooltip('show',false); + assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1, + "should have rendered a tooltip"); + await testUtils.nextTick(); + form.destroy(); + }); + QUnit.test('if the focus is on the save button, hitting ENTER should save', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" />'+ + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.ok(true, "should call the /create route"); + } + return this._super(route, args); + }, + }); + + form.$buttons.find('.o_form_button_save') + .focus() + .trigger($.Event('keydown', {which: $.ui.keyCode.ENTER})); + await testUtils.nextTick(); + form.destroy(); + }); + QUnit.test('if the focus is on the discard button, hitting ENTER should save', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" />'+ + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.ok(true, "should call the /create route"); + } + return this._super(route, args); + }, + }); + + form.$buttons.find('.o_form_button_cancel') + .focus() + .trigger($.Event('keydown', {which: $.ui.keyCode.ENTER})); + await testUtils.nextTick(); + form.destroy(); + }); + QUnit.test('if the focus is on the save button, hitting ESCAPE should discard', async function (assert) { + assert.expect(0); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" />'+ + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'create') { + throw new Error('Create should not be called'); + } + return this._super(route, args); + }, + }); + + form.$buttons.find('.o_form_button_save') + .focus() + .trigger($.Event('keydown', {which: $.ui.keyCode.ESCAPE})); + await testUtils.nextTick(); + form.destroy(); + }); + + QUnit.test('resequence list lines when discardable lines are present', async function (assert) { + assert.expect(8); + + var onchangeNum = 0; + + this.data.partner.onchanges = { + p: function (obj) { + onchangeNum++; + obj.foo = obj.p ? obj.p.length.toString() : "0"; + }, + }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '<field name="p"/>' + + '</form>', + archs: { + 'partner,false,list': + '<tree editable="bottom">' + + '<field name="int_field" widget="handle"/>' + + '<field name="display_name" required="1"/>' + + '</tree>', + }, + }); + + assert.strictEqual(onchangeNum, 1, "one onchange happens when form is opened"); + assert.strictEqual(form.$('[name="foo"]').val(), "0", "onchange worked there is 0 line"); + + // Add one line + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + form.$('.o_field_one2many input:first').focus(); + await testUtils.nextTick(); + form.$('.o_field_one2many input:first').val('first line').trigger('input'); + await testUtils.nextTick(); + await testUtils.dom.click(form.$('input[name="foo"]')); + assert.strictEqual(onchangeNum, 2, "one onchange happens when a line is added"); + assert.strictEqual(form.$('[name="foo"]').val(), "1", "onchange worked there is 1 line"); + + // Drag and drop second line before first one (with 1 draft and invalid line) + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.dom.dragAndDrop( + form.$('.ui-sortable-handle').eq(0), + form.$('.o_data_row').last(), + {position: 'bottom'} + ); + assert.strictEqual(onchangeNum, 3, "one onchange happens when lines are resequenced"); + assert.strictEqual(form.$('[name="foo"]').val(), "1", "onchange worked there is 1 line"); + + // Add a second line + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + form.$('.o_field_one2many input:first').focus(); + await testUtils.nextTick(); + form.$('.o_field_one2many input:first').val('second line').trigger('input'); + await testUtils.nextTick(); + await testUtils.dom.click(form.$('input[name="foo"]')); + assert.strictEqual(onchangeNum, 4, "one onchange happens when a line is added"); + assert.strictEqual(form.$('[name="foo"]').val(), "2", "onchange worked there is 2 lines"); + + form.destroy(); + }); + + QUnit.test('if the focus is on the discard button, hitting ESCAPE should discard', async function (assert) { + assert.expect(0); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" />'+ + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'create') { + throw new Error('Create should not be called'); + } + return this._super(route, args); + }, + }); + + form.$buttons.find('.o_form_button_cancel') + .focus() + .trigger($.Event('keydown', {which: $.ui.keyCode.ESCAPE})); + await testUtils.nextTick(); + form.destroy(); + }); + + QUnit.test('if the focus is on the save button, hitting TAB should not move to the next button', async function (assert) { + assert.expect(1); + /* + this test has only one purpose: to say that it is normal that the focus stays within a button primary even after the TAB key has been pressed. + It is not possible here to execute the default action of the TAB on a button : https://stackoverflow.com/questions/32428993/why-doesnt-simulating-a-tab-keypress-move-focus-to-the-next-input-field + so writing a test that will always succeed is not useful. + */ + assert.ok("Behavior can't be tested"); + }); + + QUnit.test('reload company when creating records of model res.company', async function (assert) { + assert.expect(6); + + var form = await createView({ + View: FormView, + model: 'res.company', + data: this.data, + arch: '<form><field name="name"/></form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + intercepts: { + do_action: function (ev) { + assert.step('reload company'); + assert.strictEqual(ev.data.action, "reload_context", "company view reloaded"); + }, + }, + }); + + await testUtils.fields.editInput(form.$('input[name="name"]'), 'Test Company'); + await testUtils.form.clickSave(form); + + assert.verifySteps([ + 'onchange', + 'create', + 'reload company', + 'read', + ]); + + form.destroy(); + }); + + QUnit.test('reload company when writing on records of model res.company', async function (assert) { + assert.expect(6); + this.data['res.company'].records = [{ + id: 1, name: "Test Company" + }]; + + var form = await createView({ + View: FormView, + model: 'res.company', + data: this.data, + arch: '<form><field name="name"/></form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + intercepts: { + do_action: function (ev) { + assert.step('reload company'); + assert.strictEqual(ev.data.action, "reload_context", "company view reloaded"); + }, + }, + }); + + await testUtils.fields.editInput(form.$('input[name="name"]'), 'Test Company2'); + await testUtils.form.clickSave(form); + + assert.verifySteps([ + 'read', + 'write', + 'reload company', + 'read', + ]); + + form.destroy(); + }); + + QUnit.test('company_dependent field in form view, in multi company group', async function (assert) { + assert.expect(2); + + this.data.partner.fields.product_id.company_dependent = true; + this.data.partner.fields.product_id.help = 'this is a tooltip'; + this.data.partner.fields.foo.company_dependent = true; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <group> + <field name="foo"/> + <field name="product_id"/> + </group> + </form>`, + session: { + display_switch_company_menu: true, + }, + }); + + const $productLabel = form.$('.o_form_label:eq(1)'); + $productLabel.tooltip('show', false); + $productLabel.trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), + "this is a tooltip\n\nValues set here are company-specific."); + $productLabel.trigger($.Event('mouseleave')); + + const $fooLabel = form.$('.o_form_label:first'); + $fooLabel.tooltip('show', false); + $fooLabel.trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), + "Values set here are company-specific."); + $fooLabel.trigger($.Event('mouseleave')); + + form.destroy(); + }); + + QUnit.test('company_dependent field in form view, not in multi company group', async function (assert) { + assert.expect(1); + + this.data.partner.fields.product_id.company_dependent = true; + this.data.partner.fields.product_id.help = 'this is a tooltip'; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: ` + <form> + <group> + <field name="product_id"/> + </group> + </form>`, + session: { + display_switch_company_menu: false, + }, + }); + + const $productLabel = form.$('.o_form_label'); + + $productLabel.tooltip('show', false); + $productLabel.trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "this is a tooltip"); + $productLabel.trigger($.Event('mouseleave')); + + form.destroy(); + }); + + QUnit.test('reload a form view with a pie chart does not crash', async function (assert) { + assert.expect(3); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: `<form> + <widget name="pie_chart" title="qux by product" attrs="{'measure\': 'qux', 'groupby': 'product_id'}"/> + </form>`, + }); + + assert.containsOnce(form, '.o_widget'); + const canvasId1 = form.el.querySelector('.o_widget canvas').id; + + await form.reload(); + await testUtils.nextTick(); + + assert.containsOnce(form, '.o_widget'); + const canvasId2 = form.el.querySelector('.o_widget canvas').id; + // A new canvas should be found in the dom + assert.notStrictEqual(canvasId1, canvasId2); + + form.destroy(); + delete widgetRegistry.map.test; + }); + + QUnit.test('do not call mounted twice on children', async function (assert) { + assert.expect(3); + + class CustomFieldComponent extends fieldRegistryOwl.get('boolean') { + mounted() { + super.mounted(...arguments); + assert.step('mounted'); + } + willUnmount() { + super.willUnmount(...arguments); + assert.step('willUnmount'); + } + } + fieldRegistryOwl.add('custom', CustomFieldComponent); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: `<form><field name="bar" widget="custom"/></form>`, + }); + + form.destroy(); + delete fieldRegistryOwl.map.custom; + + assert.verifySteps(['mounted', 'willUnmount']); + }); + + QUnit.test("attach callbacks with long processing in __renderView", async function (assert) { + /** + * The main use case of this test is discuss, in which the FormRenderer + * __renderView method is overridden to perform asynchronous tasks (the + * update of the chatter Component) resulting in a delay between the + * appending of the new form content into its element and the + * "on_attach_callback" calls. This is the purpose of "__renderView" + * which is meant to do all the async work before the content is appended. + */ + assert.expect(11); + + let testPromise = Promise.resolve(); + + const Renderer = FormRenderer.extend({ + on_attach_callback() { + assert.step("form.on_attach_callback"); + this._super(...arguments); + }, + async __renderView() { + const _super = this._super.bind(this); + await testPromise; + return _super(); + }, + }); + + // Setup custom field widget + fieldRegistry.add("customwidget", AbstractField.extend({ + className: "custom-widget", + on_attach_callback() { + assert.step("widget.on_attach_callback"); + }, + })); + + const form = await createView({ + arch: `<form><field name="bar" widget="customwidget"/></form>`, + data: this.data, + model: 'partner', + res_id: 1, + View: FormView.extend({ + config: Object.assign({}, FormView.prototype.config, { Renderer }), + }), + }); + + assert.containsOnce(form, ".custom-widget"); + assert.verifySteps([ + "form.on_attach_callback", // Form attached + "widget.on_attach_callback", // Initial widget attached + ]); + + const initialWidget = form.$(".custom-widget")[0]; + testPromise = testUtils.makeTestPromise(); + + await testUtils.form.clickEdit(form); + + assert.containsOnce(form, ".custom-widget"); + assert.strictEqual(initialWidget, form.$(".custom-widget")[0], "Widgets have yet to be replaced"); + assert.verifySteps([]); + + testPromise.resolve(); + await testUtils.nextTick(); + + assert.containsOnce(form, ".custom-widget"); + assert.notStrictEqual(initialWidget, form.$(".custom-widget")[0], "Widgets have been replaced"); + assert.verifySteps([ + "widget.on_attach_callback", // New widget attached + ]); + + form.destroy(); + + delete fieldRegistry.map.customwidget; + }); + + QUnit.test('field "length" with value 0: can apply onchange', async function (assert) { + assert.expect(1); + + this.data.partner.fields.length = {string: 'Length', type: 'float', default: 0 }; + this.data.partner.fields.foo.default = "foo default"; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="foo"/><field name="length"/></form>', + }); + + assert.strictEqual(form.$('input[name=foo]').val(), "foo default", + "should contain input with initial value"); + + form.destroy(); + }); + + QUnit.test('field "length" with value 0: readonly fields are not sent when saving', async function (assert) { + assert.expect(3); + + this.data.partner.fields.length = {string: 'Length', type: 'float', default: 0 }; + this.data.partner.fields.foo.default = "foo default"; + + // define an onchange on display_name to check that the value of readonly + // fields is correctly sent for onchanges + this.data.partner.onchanges = { + display_name: function () {}, + p: function () {}, + }; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: `<form string="Partners"> + <field name="p"> + <tree> + <field name="display_name"/> + </tree> + <form string="Partners"> + <field name="length"/> + <field name="display_name"/> + <field name="foo" attrs="{\'readonly\': [[\'display_name\', \'=\', \'readonly\']]}"/> + </form> + </field> + </form>`, + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0], { + p: [[0, args.args[0].p[0][1], {length: 0, display_name: 'readonly'}]] + }, "should not have sent the value of the readonly field"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + assert.containsOnce(document.body, '.modal input.o_field_widget[name=foo]', + 'foo should be editable'); + await testUtils.fields.editInput($('.modal .o_field_widget[name=foo]'), 'foo value'); + await testUtils.fields.editInput($('.modal .o_field_widget[name=display_name]'), 'readonly'); + assert.containsOnce(document.body, '.modal span.o_field_widget[name=foo]', + 'foo should be readonly'); + await testUtils.dom.clickFirst($('.modal-footer .btn-primary')); + + await testUtils.form.clickSave(form); // save the record + form.destroy(); + }); + +}); + +}); diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js new file mode 100644 index 00000000..fda4c5cb --- /dev/null +++ b/addons/web/static/tests/views/graph_tests.js @@ -0,0 +1,2048 @@ +odoo.define('web.graph_view_tests', function (require) { +"use strict"; + +var searchUtils = require('web.searchUtils'); +var GraphView = require('web.GraphView'); +var testUtils = require('web.test_utils'); +const { sortBy } = require('web.utils'); + +const cpHelpers = testUtils.controlPanel; +var createView = testUtils.createView; +var patchDate = testUtils.mock.patchDate; + +const { INTERVAL_OPTIONS, PERIOD_OPTIONS, COMPARISON_OPTIONS } = searchUtils; + +var INTERVAL_OPTION_IDS = Object.keys(INTERVAL_OPTIONS); + +const yearIds = []; +const otherIds = []; +for (const id of Object.keys(PERIOD_OPTIONS)) { + const option = PERIOD_OPTIONS[id]; + if (option.granularity === 'year') { + yearIds.push(id); + } else { + otherIds.push(id); + } +} +const BASIC_DOMAIN_IDS = []; +for (const yearId of yearIds) { + BASIC_DOMAIN_IDS.push(yearId); + for (const id of otherIds) { + BASIC_DOMAIN_IDS.push(`${yearId}__${id}`); + } +} +const GENERATOR_INDEXES = {}; +let index = 0; +for (const id of Object.keys(PERIOD_OPTIONS)) { + GENERATOR_INDEXES[id] = index++; +} + +const COMPARISON_OPTION_IDS = Object.keys(COMPARISON_OPTIONS); +const COMPARISON_OPTION_INDEXES = {}; +index = 0; +for (const comparisonOptionId of COMPARISON_OPTION_IDS) { + COMPARISON_OPTION_INDEXES[comparisonOptionId] = index++; +} + +var f = (a, b) => [].concat(...a.map(d => b.map(e => [].concat(d, e)))); +var cartesian = (a, b, ...c) => (b ? cartesian(f(a, b), ...c) : a); + +var COMBINATIONS = cartesian(COMPARISON_OPTION_IDS, BASIC_DOMAIN_IDS); +var COMBINATIONS_WITH_DATE = cartesian(COMPARISON_OPTION_IDS, BASIC_DOMAIN_IDS, INTERVAL_OPTION_IDS); + +QUnit.assert.checkDatasets = function (graph, keys, expectedDatasets) { + keys = keys instanceof Array ? keys : [keys]; + expectedDatasets = expectedDatasets instanceof Array ? + expectedDatasets : + [expectedDatasets]; + var datasets = graph.renderer.chart.data.datasets; + var actualValues = datasets.map(dataset => _.pick(dataset, keys)); + this.pushResult({ + result: _.isEqual(actualValues, expectedDatasets), + actual: actualValues, + expected: expectedDatasets, + }); +}; + +QUnit.assert.checkLabels = function (graph, expectedLabels) { + var labels = graph.renderer.chart.data.labels; + this.pushResult({ + result: _.isEqual(labels, expectedLabels), + actual: labels, + expected: expectedLabels, + }); +}; + +QUnit.assert.checkLegend = function (graph, expectedLegendLabels) { + expectedLegendLabels = expectedLegendLabels instanceof Array ? + expectedLegendLabels : + [expectedLegendLabels]; + var chart = graph.renderer.chart; + var actualLegendLabels = chart.config.options.legend.labels.generateLabels(chart).map(o => o.text); + + this.pushResult({ + result: _.isEqual(actualLegendLabels, expectedLegendLabels), + actual: actualLegendLabels, + expected: expectedLegendLabels, + }); +}; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + foo: { + fields: { + foo: {string: "Foo", type: "integer", store: true}, + bar: {string: "bar", type: "boolean"}, + product_id: {string: "Product", type: "many2one", relation: 'product', store: true}, + color_id: {string: "Color", type: "many2one", relation: 'color'}, + date: {string: "Date", type: 'date', store: true, sortable: true}, + revenue: {string: "Revenue", type: 'integer', store: true}, + }, + records: [ + {id: 1, foo: 3, bar: true, product_id: 37, date: "2016-01-01", revenue: 1}, + {id: 2, foo: 53, bar: true, product_id: 37, color_id: 7, date: "2016-01-03", revenue: 2}, + {id: 3, foo: 2, bar: true, product_id: 37, date: "2016-03-04", revenue: 3}, + {id: 4, foo: 24, bar: false, product_id: 37, date: "2016-03-07", revenue: 4}, + {id: 5, foo: 4, bar: false, product_id: 41, date: "2016-05-01", revenue: 5}, + {id: 6, foo: 63, bar: false, product_id: 41}, + {id: 7, foo: 42, bar: false, product_id: 41}, + {id: 8, foo: 48, bar: false, product_id: 41, date: "2016-04-01", revenue: 8}, + ] + }, + product: { + fields: { + name: {string: "Product Name", type: "char"} + }, + records: [{ + id: 37, + display_name: "xphone", + }, { + id: 41, + display_name: "xpad", + }] + }, + color: { + fields: { + name: {string: "Color", type: "char"} + }, + records: [{ + id: 7, + display_name: "red", + }, { + id: 14, + display_name: "black", + }] + }, + }; + } +}, function () { + + QUnit.module('GraphView'); + + QUnit.test('simple graph rendering', async function (assert) { + assert.expect(5); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="bar"/>' + + '</graph>', + }); + + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas', + "should contain a div with a canvas element"); + assert.strictEqual(graph.renderer.state.mode, "bar", + "should be in bar chart mode by default"); + assert.checkLabels(graph, [[true], [false]]); + assert.checkDatasets(graph, + ['backgroundColor', 'data', 'label', 'originIndex', 'stack'], + { + backgroundColor: "#1f77b4", + data: [3,5], + label: "Count", + originIndex: 0, + stack: "", + } + ); + assert.checkLegend(graph, 'Count'); + + graph.destroy(); + }); + + QUnit.test('default type attribute', async function (assert) { + assert.expect(1); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="pie">' + + '<field name="bar"/>' + + '</graph>', + }); + + assert.strictEqual(graph.renderer.state.mode, "pie", "should be in pie chart mode by default"); + + graph.destroy(); + }); + + QUnit.test('title attribute', async function (assert) { + assert.expect(1); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph title="Partners" type="pie">' + + '<field name="bar"/>' + + '</graph>', + }); + + assert.strictEqual(graph.$('.o_graph_renderer label').text(), "Partners", + "should have 'Partners as title'"); + + graph.destroy(); + }); + + QUnit.test('field id not in groupBy', async function (assert) { + assert.expect(1); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="id"/>' + + '</graph>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.groupby, [], + 'groupby should not contain id field'); + } + return this._super.apply(this, arguments); + }, + }); + graph.destroy(); + }); + + QUnit.test('switching mode', async function (assert) { + assert.expect(6); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="line">' + + '<field name="bar"/>' + + '</graph>', + }); + + assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode by default"); + assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', + 'bar type button should not be active'); + assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', + 'line type button should be active'); + + await testUtils.dom.click(graph.$buttons.find('button[data-mode="bar"]')); + assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode by default"); + assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="line"]'), 'active', + 'line type button should not be active'); + assert.hasClass(graph.$buttons.find('button[data-mode="bar"]'),'active', + 'bar type button should be active'); + + graph.destroy(); + }); + + QUnit.test('displaying line chart with only 1 data point', async function (assert) { + assert.expect(1); + // this test makes sure the line chart does not crash when only one data + // point is displayed. + this.data.foo.records = this.data.foo.records.slice(0,1); + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="line">' + + '<field name="bar"/>' + + '</graph>', + }); + + assert.containsOnce(graph, 'canvas', "should have a canvas"); + + graph.destroy(); + }); + + QUnit.test('displaying chart data with multiple groupbys', async function (assert) { + // this test makes sure the line chart shows all data labels (X axis) when + // it is grouped by several fields + assert.expect(6); + + var graph = await createView({ + View: GraphView, + model: 'foo', + data: this.data, + arch: '<graph type="bar"><field name="foo" /></graph>', + groupBy: ['product_id', 'bar', 'color_id'], + }); + + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, ['true/Undefined', 'true/red', 'false/Undefined']); + + await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]')); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, ['true/Undefined', 'true/red', 'false/Undefined']); + + await testUtils.dom.click(graph.$buttons.find('button[data-mode="pie"]')); + assert.checkLabels(graph, [ + ["xphone", true, "Undefined"], + ["xphone", true,"red"], + ["xphone", false, "Undefined"], + ["xpad", false, "Undefined"] + ]); + assert.checkLegend(graph, [ + 'xphone/true/Undefined', + 'xphone/true/red', + 'xphone/false/Undefined', + 'xpad/false/Undefined' + ]); + + graph.destroy(); + }); + + QUnit.test('switching measures', async function (assert) { + assert.expect(2); + + var rpcCount = 0; + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Gloups">' + + '<field name="product_id"/>' + + '</graph>', + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + await cpHelpers.toggleMenu(graph, "Measures"); + await cpHelpers.toggleMenuItem(graph, "Foo"); + + assert.checkLegend(graph, 'Foo'); + assert.strictEqual(rpcCount, 2, "should have done 2 rpcs (2 readgroups)"); + + graph.destroy(); + }); + + QUnit.test('no content helper (bar chart)', async function (assert) { + assert.expect(3); + this.data.foo.records = []; + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: ` + <graph string="Gloups"> + <field name="product_id"/> + </graph>`, + viewOptions: { + action: { + help: '<p class="abc">This helper should not be displayed in graph views</p>' + } + }, + }); + + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas'); + assert.containsNone(graph, 'div.o_view_nocontent'); + assert.containsNone(graph, '.abc'); + + graph.destroy(); + }); + + QUnit.test('no content helper (pie chart)', async function (assert) { + assert.expect(3); + this.data.foo.records = []; + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: ` + <graph type="pie"> + <field name="product_id"/> + </graph>`, + viewOptions: { + action: { + help: '<p class="abc">This helper should not be displayed in graph views</p>' + } + }, + }); + + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas'); + assert.containsNone(graph, 'div.o_view_nocontent'); + assert.containsNone(graph, '.abc'); + + graph.destroy(); + }); + + QUnit.test('render pie chart in comparison mode', async function (assert) { + assert.expect(2); + + const unpatchDate = patchDate(2020, 4, 19, 1, 0, 0); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + context: { search_default_date_filter: 1, }, + arch: '<graph type="pie">' + + '<field name="product_id"/>' + + '</graph>', + archs: { + 'foo,false,search': ` + <search> + <filter name="date_filter" domain="[]" date="date" default_period="third_quarter"/> + </search> + `, + }, + }); + + await cpHelpers.toggleComparisonMenu(graph); + await cpHelpers.toggleMenuItem(graph, 'Date: Previous period'); + + assert.containsNone(graph, 'div.o_view_nocontent', + "should not display the no content helper"); + assert.checkLegend(graph, 'No data'); + + unpatchDate(); + graph.destroy(); + }); + + QUnit.test('no content helper after update', async function (assert) { + assert.expect(6); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: ` + <graph string="Gloups"> + <field name="product_id"/> + </graph>`, + viewOptions: { + action: { + help: '<p class="abc">This helper should not be displayed in graph views</p>' + } + }, + }); + + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas'); + assert.containsNone(graph, 'div.o_view_nocontent'); + assert.containsNone(graph, '.abc'); + + await testUtils.graph.reload(graph, {domain: [['product_id', '<', 0]]}); + + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas'); + assert.containsNone(graph, 'div.o_view_nocontent'); + assert.containsNone(graph, '.abc'); + + graph.destroy(); + }); + + QUnit.test('can reload with other group by', async function (assert) { + assert.expect(2); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Gloups">' + + '<field name="product_id"/>' + + '</graph>', + }); + + assert.checkLabels(graph, [['xphone'], ['xpad']]); + + await testUtils.graph.reload(graph, {groupBy: ['color_id']}); + assert.checkLabels(graph, [['Undefined'], ['red']]); + + graph.destroy(); + }); + + QUnit.test('getOwnedQueryParams correctly returns mode, measure, and groupbys', async function (assert) { + assert.expect(4); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Gloups">' + + '<field name="product_id"/>' + + '</graph>', + }); + + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'bar', + graph_measure: '__count__', + graph_groupbys: ['product_id'], + } + }, "context should be correct"); + + await cpHelpers.toggleMenu(graph, "Measures"); + await cpHelpers.toggleMenuItem(graph, "Foo"); + + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'bar', + graph_measure: 'foo', + graph_groupbys: ['product_id'], + }, + }, "context should be correct"); + + await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]')); + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'line', + graph_measure: 'foo', + graph_groupbys: ['product_id'], + }, + }, "context should be correct"); + + await testUtils.graph.reload(graph, {groupBy: ['product_id', 'color_id']}); // change groupbys + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'line', + graph_measure: 'foo', + graph_groupbys: ['product_id', 'color_id'], + }, + }, "context should be correct"); + + graph.destroy(); + }); + + QUnit.test('correctly uses graph_ keys from the context', async function (assert) { + assert.expect(5); + + var lastOne = _.last(this.data.foo.records); + lastOne.color_id = 14; + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph><field name="product_id"/></graph>', + viewOptions: { + context: { + graph_measure: 'foo', + graph_mode: 'line', + graph_groupbys: ['color_id'], + }, + }, + }); + // check measure name is present in legend + assert.checkLegend(graph, 'Foo'); + // check mode + assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode"); + assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', + 'bar chart button should not be active'); + assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', + 'line chart button should be active'); + // check groupby values ('Undefined' is rejected in line chart) are in labels + assert.checkLabels(graph, [['red'], ['black']]); + + graph.destroy(); + }); + + QUnit.test('correctly use group_by key from the context', async function (assert) { + assert.expect(1); + + var lastOne = _.last(this.data.foo.records); + lastOne.color_id = 14; + + var graph = await createView({ + View: GraphView, + model: 'foo', + data: this.data, + arch: '<graph><field name="product_id" /></graph>', + groupBy: ['color_id'], + viewOptions: { + context: { + graph_measure: 'foo', + graph_mode: 'line', + }, + }, + }); + // check groupby values ('Undefined' is rejected in line chart) are in labels + assert.checkLabels(graph, [['red'], ['black']]); + + graph.destroy(); + }); + + QUnit.test('correctly uses graph_ keys from the context (at reload)', async function (assert) { + assert.expect(7); + + var lastOne = _.last(this.data.foo.records); + lastOne.color_id = 14; + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph><field name="product_id"/></graph>', + }); + + assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode"); + assert.hasClass(graph.$buttons.find('button[data-mode="bar"]'),'active', + 'bar chart button should be active'); + + var reloadParams = { + context: { + graph_measure: 'foo', + graph_mode: 'line', + graph_groupbys: ['color_id'], + }, + }; + await testUtils.graph.reload(graph, reloadParams); + + // check measure + assert.checkLegend(graph, 'Foo'); + // check mode + assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode"); + assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', + 'bar chart button should not be active'); + assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', + 'line chart button should be active'); + // check groupby values ('Undefined' is rejected in line chart) are in labels + assert.checkLabels(graph, [['red'], ['black']]); + + graph.destroy(); + }); + + QUnit.test('reload graph with correct fields', async function (assert) { + assert.expect(2); + + var graph = await createView({ + View: GraphView, + model: 'foo', + data: this.data, + arch: '<graph>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</graph>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.fields, ['product_id', 'foo'], + "should read the correct fields"); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.graph.reload(graph, {groupBy: []}); + + graph.destroy(); + }); + + QUnit.test('initial groupby is kept when reloading', async function (assert) { + assert.expect(8); + + var graph = await createView({ + View: GraphView, + model: 'foo', + data: this.data, + arch: '<graph>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</graph>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.groupby, ['product_id'], + "should group by the correct field"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [82, 157]}); + + await testUtils.graph.reload(graph, {groupBy: []}); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [82, 157]}); + + graph.destroy(); + }); + + QUnit.test('use a many2one as a measure should work (without groupBy)', async function (assert) { + assert.expect(4); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="product_id" type="measure"/>' + + '</graph>', + }); + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas', + "should contain a div with a canvas element"); + assert.checkLabels(graph, [[]]); + assert.checkLegend(graph, 'Product'); + assert.checkDatasets(graph, 'data', {data: [2]}); + + graph.destroy(); + }); + + QUnit.test('use a many2one as a measure should work (with groupBy)', async function (assert) { + assert.expect(5); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="bar" type="row"/>' + + '<field name="product_id" type="measure"/>' + + '</graph>', + }); + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas', + "should contain a div with a canvas element"); + + assert.strictEqual(graph.renderer.state.mode, "bar", + "should be in bar chart mode by default"); + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, 'Product'); + assert.checkDatasets(graph, 'data', {data: [1, 2]}); + + graph.destroy(); + }); + + QUnit.test('use a many2one as a measure and as a groupby should work', async function (assert) { + assert.expect(3); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="product_id" type="row"/>' + + '</graph>', + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + + // need to set the measure this way because it cannot be set in the + // arch. + await cpHelpers.toggleMenu(graph, "Measures"); + await cpHelpers.toggleMenuItem(graph, "Product"); + + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, 'Product'); + assert.checkDatasets(graph, 'data', {data: [1, 1]}); + + graph.destroy(); + }); + + QUnit.test('not use a many2one as a measure by default', async function (assert) { + assert.expect(1); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="product_id"/>' + + '</graph>', + }); + assert.notOk(graph.measures.product_id, + "should not have product_id as measure"); + graph.destroy(); + }); + + QUnit.test('use a many2one as a measure if set as additional fields', async function (assert) { + assert.expect(1); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="product_id"/>' + + '</graph>', + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + + assert.ok(graph.measures.find(m => m.fieldName === 'product_id'), + "should have product_id as measure"); + + graph.destroy(); + }); + + QUnit.test('measure dropdown consistency', async function (assert) { + assert.expect(2); + + const actionManager = await testUtils.createActionManager({ + archs: { + 'foo,false,graph': ` + <graph string="Partners" type="bar"> + <field name="foo" type="measure"/> + </graph>`, + 'foo,false,search': `<search/>`, + 'foo,false,kanban': ` + <kanban> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + }, + data: this.data, + }); + await actionManager.doAction({ + res_model: 'foo', + type: 'ir.actions.act_window', + views: [[false, 'graph'], [false, 'kanban']], + flags: { + graph: { + additionalMeasures: ['product_id'], + } + }, + }); + + assert.containsOnce(actionManager, '.o_control_panel .o_graph_measures_list', + "Measures dropdown is present at init" + ); + + await cpHelpers.switchView(actionManager, 'kanban'); + await cpHelpers.switchView(actionManager, 'graph'); + + assert.containsOnce(actionManager, '.o_control_panel .o_graph_measures_list', + "Measures dropdown is present after reload" + ); + + actionManager.destroy(); + }); + + QUnit.test('graph view crash when moving from search view using Down key', async function (assert) { + assert.expect(1); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="pie">' + + '<field name="bar"/>' + + '</graph>', + }); + graph._giveFocus(); + assert.ok(true,"should not generate any error"); + graph.destroy(); + }); + + QUnit.test('graph measures should be alphabetically sorted', async function (assert) { + assert.expect(2); + + var data = this.data; + data.foo.fields.bouh = {string: "bouh", type: "integer"}; + + var graph = await createView({ + View: GraphView, + model: "foo", + data: data, + arch: '<graph string="Partners">' + + '<field name="foo" type="measure"/>' + + '<field name="bouh" type="measure"/>' + + '</graph>', + }); + + await cpHelpers.toggleMenu(graph, "Measures"); + assert.strictEqual(graph.$buttons.find('.o_graph_measures_list .dropdown-item:first').text(), 'bouh', + "Bouh should be the first measure"); + assert.strictEqual(graph.$buttons.find('.o_graph_measures_list .dropdown-item:last').text(), 'Count', + "Count should be the last measure"); + + graph.destroy(); + }); + + QUnit.test('Undefined should appear in bar, pie graph but not in line graph', async function (assert) { + assert.expect(3); + + var graph = await createView({ + View: GraphView, + model: "foo", + groupBy:['date'], + data: this.data, + arch: '<graph string="Partners" type="line">' + + '<field name="bar"/>' + + '</graph>', + }); + + function _indexOf (label) { + return graph.renderer._indexOf(graph.renderer.chart.data.labels, label); + } + + assert.strictEqual(_indexOf(['Undefined']), -1); + + await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=bar]')); + assert.ok(_indexOf(['Undefined']) >= 0); + + await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=pie]')); + assert.ok(_indexOf(['Undefined']) >= 0); + + graph.destroy(); + }); + + QUnit.test('Undefined should appear in bar, pie graph but not in line graph with multiple groupbys', async function (assert) { + assert.expect(4); + + var graph = await createView({ + View: GraphView, + model: "foo", + groupBy:['date', 'color_id'], + data: this.data, + arch: '<graph string="Partners" type="line">' + + '<field name="bar"/>' + + '</graph>', + }); + + function _indexOf (label) { + return graph.renderer._indexOf(graph.renderer.chart.data.labels, label); + } + + assert.strictEqual(_indexOf(['Undefined']), -1); + + await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=bar]')); + assert.ok(_indexOf(['Undefined']) >= 0); + + await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=pie]')); + var labels = graph.renderer.chart.data.labels; + assert.ok(labels.filter(label => /Undefined/.test(label.join(''))).length >= 1); + + // Undefined should not appear after switching back to line chart + await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=line]')); + assert.strictEqual(_indexOf(['Undefined']), -1); + + graph.destroy(); + }); + + QUnit.test('no comparison and no groupby', async function (assert) { + assert.expect(9); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="bar">' + + '<field name="foo" type="measure"/>' + + '</graph>', + }); + + + assert.checkLabels(graph, [[]]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [239]}); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]')); + // the labels in line chart is translated in this case to avoid to have a single + // point at the left of the screen and chart to seem empty. + assert.checkLabels(graph, [[''], [], ['']]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [undefined, 239]}); + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]')); + assert.checkLabels(graph, [[]]); + assert.checkLegend(graph, 'Total'); + assert.checkDatasets(graph, 'data', {data: [239]}); + + graph.destroy(); + }); + + QUnit.test('no comparison and one groupby', async function (assert) { + assert.expect(9); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="bar">' + + '<field name="foo" type="measure"/>' + + '<field name="bar" type="row"/>' + + '</graph>', + }); + + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [58, 181]}); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]')); + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [58, 181]}); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]')); + + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, ['true', 'false']); + assert.checkDatasets(graph, 'data', {data: [58, 181]}); + + graph.destroy(); + }); + QUnit.test('no comparison and two groupby', async function (assert) { + assert.expect(9); + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="bar">' + + '<field name="foo" type="measure"/>' + + '</graph>', + groupBy: ['product_id', 'color_id'], + }); + + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, ['Undefined', 'red']); + assert.checkDatasets(graph, ['label', 'data'], [ + { + label: 'Undefined', + data: [29, 157], + }, + { + label: 'red', + data: [53, 0], + } + ]); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]')); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, ['Undefined', 'red']); + assert.checkDatasets(graph, ['label', 'data'], [ + { + label: 'Undefined', + data: [29, 157], + }, + { + label: 'red', + data: [53, 0], + } + ]); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]')); + assert.checkLabels(graph, [['xphone', 'Undefined'], ['xphone', 'red'], ['xpad', 'Undefined']]); + assert.checkLegend(graph, ['xphone/Undefined', 'xphone/red', 'xpad/Undefined']); + assert.checkDatasets(graph, ['label', 'data'], { + label: '', + data: [29, 53, 157], + }); + + graph.destroy(); + }); + + QUnit.test('graph view only keeps finer groupby filter option for a given groupby', async function (assert) { + assert.expect(3); + + var graph = await createView({ + View: GraphView, + model: "foo", + groupBy:['date:year','product_id', 'date', 'date:quarter'], + data: this.data, + arch: '<graph string="Partners" type="line">' + + '<field name="bar"/>' + + '</graph>', + }); + + assert.checkLabels(graph, [["January 2016"], ["March 2016"], ["May 2016"], ["April 2016"]]); + // mockReadGroup does not always sort groups -> May 2016 is before April 2016 for that reason. + assert.checkLegend(graph, ["xphone","xpad"]); + assert.checkDatasets(graph, ['label', 'data'], [ + { + label: 'xphone', + data: [2, 2, 0, 0], + }, { + label: 'xpad', + data: [0, 0, 1, 1], + } + ]); + + graph.destroy(); + }); + + QUnit.test('clicking on bar and pie charts triggers a do_action', async function (assert) { + assert.expect(5); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Foo Analysis"><field name="bar"/></graph>', + intercepts: { + do_action: function (ev) { + assert.deepEqual(ev.data.action, { + context: {}, + domain: [["bar", "=", true]], + name: "Foo Analysis", + res_model: "foo", + target: 'current', + type: 'ir.actions.act_window', + view_mode: 'list', + views: [[false, 'list'], [false, 'form']], + }, "should trigger do_action with correct action parameter"); + } + }, + }); + await testUtils.nextTick(); // wait for the graph to be rendered + + // bar mode + assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode"); + assert.checkDatasets(graph, ['domain'], { + domain: [[["bar", "=", true]], [["bar", "=", false]]], + }); + + let myChart = graph.renderer.chart; + let meta = myChart.getDatasetMeta(0); + let rectangle = myChart.canvas.getBoundingClientRect(); + let point = meta.data[0].getCenterPoint(); + await testUtils.dom.triggerEvent(myChart.canvas, 'click', { + pageX: rectangle.left + point.x, + pageY: rectangle.top + point.y + }); + + // pie mode + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]')); + assert.strictEqual(graph.renderer.state.mode, "pie", "should be in pie chart mode"); + + myChart = graph.renderer.chart; + meta = myChart.getDatasetMeta(0); + rectangle = myChart.canvas.getBoundingClientRect(); + point = meta.data[0].getCenterPoint(); + await testUtils.dom.triggerEvent(myChart.canvas, 'click', { + pageX: rectangle.left + point.x, + pageY: rectangle.top + point.y + }); + + graph.destroy(); + }); + + QUnit.test('clicking charts trigger a do_action with correct views', async function (assert) { + assert.expect(3); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Foo Analysis"><field name="bar"/></graph>', + intercepts: { + do_action: function (ev) { + assert.deepEqual(ev.data.action, { + context: {}, + domain: [["bar", "=", true]], + name: "Foo Analysis", + res_model: "foo", + target: 'current', + type: 'ir.actions.act_window', + view_mode: 'list', + views: [[364, 'list'], [29, 'form']], + }, "should trigger do_action with correct action parameter"); + } + }, + viewOptions: { + actionViews: [{ + type: 'list', + viewID: 364, + }, { + type: 'form', + viewID: 29, + }], + }, + }); + await testUtils.nextTick(); // wait for the graph to be rendered + + assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode"); + assert.checkDatasets(graph, ['domain'], { + domain: [[["bar", "=", true]], [["bar", "=", false]]], + }); + + let myChart = graph.renderer.chart; + let meta = myChart.getDatasetMeta(0); + let rectangle = myChart.canvas.getBoundingClientRect(); + let point = meta.data[0].getCenterPoint(); + await testUtils.dom.triggerEvent(myChart.canvas, 'click', { + pageX: rectangle.left + point.x, + pageY: rectangle.top + point.y + }); + + graph.destroy(); + }); + + QUnit.test('graph view with attribute disable_linking="True"', async function (assert) { + assert.expect(2); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph disable_linking="1"><field name="bar"/></graph>', + intercepts: { + do_action: function () { + throw new Error('Should not perform a do_action'); + }, + }, + }); + await testUtils.nextTick(); // wait for the graph to be rendered + + assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode"); + assert.checkDatasets(graph, ['domain'], { + domain: [[["bar", "=", true]], [["bar", "=", false]]], + }); + + let myChart = graph.renderer.chart; + let meta = myChart.getDatasetMeta(0); + let rectangle = myChart.canvas.getBoundingClientRect(); + let point = meta.data[0].getCenterPoint(); + await testUtils.dom.triggerEvent(myChart.canvas, 'click', { + pageX: rectangle.left + point.x, + pageY: rectangle.top + point.y + }); + + graph.destroy(); + }); + + QUnit.test('graph view without invisible attribute on field', async function (assert) { + assert.expect(4); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: `<graph string="Partners"></graph>`, + }); + + await testUtils.dom.click(graph.$('.btn-group:first button')); + assert.containsN(graph, 'li.o_menu_item', 3, + "there should be three menu item in the measures dropdown (count, revenue and foo)"); + assert.containsOnce(graph, 'li.o_menu_item a:contains("Revenue")'); + assert.containsOnce(graph, 'li.o_menu_item a:contains("Foo")'); + assert.containsOnce(graph, 'li.o_menu_item a:contains("Count")'); + + graph.destroy(); + }); + + QUnit.test('graph view with invisible attribute on field', async function (assert) { + assert.expect(2); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: ` + <graph string="Partners"> + <field name="revenue" invisible="1"/> + </graph>`, + }); + + await testUtils.dom.click(graph.$('.btn-group:first button')); + assert.containsN(graph, 'li.o_menu_item', 2, + "there should be only two menu item in the measures dropdown (count and foo)"); + assert.containsNone(graph, 'li.o_menu_item a:contains("Revenue")'); + + graph.destroy(); + }); + + QUnit.test('graph view sort by measure', async function (assert) { + assert.expect(18); + + // change first record from foo as there are 4 records count for each product + this.data.product.records.push({ id: 38, display_name: "zphone"}); + this.data.foo.records[7].product_id = 38; + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: `<graph string="Partners" order="desc"> + <field name="product_id"/> + </graph>`, + }); + + assert.containsN(graph, 'button[data-order]', 2, + "there should be two order buttons for sorting axis labels in bar mode"); + assert.checkLegend(graph, 'Count', 'measure should be by count'); + assert.hasClass(graph.$('button[data-order="desc"]'), 'active', + 'sorting should be applie on descending order by default when sorting="desc"'); + assert.checkDatasets(graph, 'data', {data: [4, 3, 1]}); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]')); + assert.hasClass(graph.$('button[data-order="asc"]'), 'active', + "ascending order should be applied"); + assert.checkDatasets(graph, 'data', {data: [1, 3, 4]}); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]')); + assert.hasClass(graph.$('button[data-order="desc"]'), 'active', + "descending order button should be active"); + assert.checkDatasets(graph, 'data', { data: [4, 3, 1] }); + + // again click on descending button to deactivate order button + await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]')); + assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active', + "descending order button should not be active"); + assert.checkDatasets(graph, 'data', {data: [4, 3, 1]}); + + // set line mode + await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]')); + assert.containsN(graph, 'button[data-order]', 2, + "there should be two order buttons for sorting axis labels in line mode"); + assert.checkLegend(graph, 'Count', 'measure should be by count'); + assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active', + "descending order should be applied"); + assert.checkDatasets(graph, 'data', {data: [4, 3, 1]}); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]')); + assert.hasClass(graph.$('button[data-order="asc"]'), 'active', + "ascending order button should be active"); + assert.checkDatasets(graph, 'data', { data: [1, 3, 4] }); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]')); + assert.hasClass(graph.$('button[data-order="desc"]'), 'active', + "descending order button should be active"); + assert.checkDatasets(graph, 'data', { data: [4, 3, 1] }); + + graph.destroy(); + }); + + QUnit.test('graph view sort by measure for grouped data', async function (assert) { + assert.expect(9); + + // change first record from foo as there are 4 records count for each product + this.data.product.records.push({ id: 38, display_name: "zphone", }); + this.data.foo.records[7].product_id = 38; + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: `<graph string="Partners"> + <field name="product_id"/> + <field name="bar"/> + </graph>`, + }); + + assert.checkLegend(graph, ["true","false"], 'measure should be by count'); + assert.containsN(graph, 'button[data-order]', 2, + "there should be two order buttons for sorting axis labels"); + assert.checkDatasets(graph, 'data', [{data: [3, 0, 0]}, {data: [1, 3, 1]}]); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]')); + assert.hasClass(graph.$('button[data-order="asc"]'), 'active', + "ascending order should be applied by default"); + assert.checkDatasets(graph, 'data', [{ data: [1, 3, 1] }, { data: [0, 0, 3] }]); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]')); + assert.hasClass(graph.$('button[data-order="desc"]'), 'active', + "ascending order button should be active"); + assert.checkDatasets(graph, 'data', [{data: [1, 3, 1]}, {data: [3, 0, 0]}]); + + // again click on descending button to deactivate order button + await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]')); + assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active', + "descending order button should not be active"); + assert.checkDatasets(graph, 'data', [{ data: [3, 0, 0] }, { data: [1, 3, 1] }]); + + graph.destroy(); + }); + + QUnit.test('graph view sort by measure for multiple grouped data', async function (assert) { + assert.expect(9); + + // change first record from foo as there are 4 records count for each product + this.data.product.records.push({ id: 38, display_name: "zphone" }); + this.data.foo.records[7].product_id = 38; + + // add few more records to data to have grouped data date wise + const data = [ + {id: 9, foo: 48, bar: false, product_id: 41, date: "2016-04-01"}, + {id: 10, foo: 49, bar: false, product_id: 41, date: "2016-04-01"}, + {id: 11, foo: 50, bar: true, product_id: 37, date: "2016-01-03"}, + {id: 12, foo: 50, bar: true, product_id: 41, date: "2016-01-03"}, + ]; + + Object.assign(this.data.foo.records, data); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: `<graph string="Partners"> + <field name="product_id"/> + <field name="date"/> + </graph>`, + groupBy: ['date', 'product_id'] + }); + + assert.checkLegend(graph, ["xpad","xphone","zphone"], 'measure should be by count'); + assert.containsN(graph, 'button[data-order]', 2, + "there should be two order buttons for sorting axis labels"); + assert.checkDatasets(graph, 'data', [{data: [2, 1, 1, 2]}, {data: [0, 1, 0, 0]}, {data: [1, 0, 0, 0]}]); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]')); + assert.hasClass(graph.$('button[data-order="asc"]'), 'active', + "ascending order should be applied by default"); + assert.checkDatasets(graph, 'data', [{ data: [1, 1, 2, 2] }, { data: [0, 1, 0, 0] }, { data: [0, 0, 0, 1] }]); + + await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]')); + assert.hasClass(graph.$('button[data-order="desc"]'), 'active', + "descending order button should be active"); + assert.checkDatasets(graph, 'data', [{data: [1, 0, 0, 0]}, {data: [2, 2, 1, 1]}, {data: [0, 0, 1, 0]}]); + + // again click on descending button to deactivate order button + await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]')); + assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active', + "descending order button should not be active"); + assert.checkDatasets(graph, 'data', [{ data: [2, 1, 1, 2] }, { data: [0, 1, 0, 0] }, { data: [1, 0, 0, 0] }]); + + graph.destroy(); + }); + + QUnit.test('empty graph view with sample data', async function (assert) { + assert.expect(8); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: ` + <graph sample="1"> + <field name="product_id"/> + <field name="date"/> + </graph>`, + domain: [['id', '<', 0]], + viewOptions: { + action: { + help: '<p class="abc">click to add a foo</p>' + } + }, + }); + + assert.hasClass(graph.el, 'o_view_sample_data'); + assert.containsOnce(graph, '.o_view_nocontent'); + assert.containsOnce(graph, '.o_graph_canvas_container canvas'); + assert.hasClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled'); + + await graph.reload({ domain: [] }); + + assert.doesNotHaveClass(graph.el, 'o_view_sample_data'); + assert.containsNone(graph, '.o_view_nocontent'); + assert.containsOnce(graph, '.o_graph_canvas_container canvas'); + assert.doesNotHaveClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled'); + + graph.destroy(); + }); + + QUnit.test('non empty graph view with sample data', async function (assert) { + assert.expect(8); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: ` + <graph sample="1"> + <field name="product_id"/> + <field name="date"/> + </graph>`, + viewOptions: { + action: { + help: '<p class="abc">click to add a foo</p>' + } + }, + }) + + assert.doesNotHaveClass(graph.el, 'o_view_sample_data'); + assert.containsNone(graph, '.o_view_nocontent'); + assert.containsOnce(graph, '.o_graph_canvas_container canvas'); + assert.doesNotHaveClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled'); + + await graph.reload({ domain: [['id', '<', 0]] }); + + assert.doesNotHaveClass(graph.el, 'o_view_sample_data'); + assert.containsOnce(graph, '.o_graph_canvas_container canvas'); + assert.doesNotHaveClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled'); + assert.containsNone(graph, '.o_view_nocontent'); + + graph.destroy(); + }); + + QUnit.module('GraphView: comparison mode', { + beforeEach: async function () { + this.data.foo.records[0].date = '2016-12-15'; + this.data.foo.records[1].date = '2016-12-17'; + this.data.foo.records[2].date = '2016-11-22'; + this.data.foo.records[3].date = '2016-11-03'; + this.data.foo.records[4].date = '2016-12-20'; + this.data.foo.records[5].date = '2016-12-19'; + this.data.foo.records[6].date = '2016-12-15'; + this.data.foo.records[7].date = undefined; + + this.data.foo.records.push({id: 9, foo: 48, bar: false, product_id: 41, color_id: 7, date: "2016-12-01"}); + this.data.foo.records.push({id: 10, foo: 17, bar: true, product_id: 41, color_id: 7, date: "2016-12-01"}); + this.data.foo.records.push({id: 11, foo: 45, bar: true, product_id: 37, color_id: 14, date: "2016-12-01"}); + this.data.foo.records.push({id: 12, foo: 48, bar: false, product_id: 37, color_id: 7, date: "2016-12-10"}); + this.data.foo.records.push({id: 13, foo: 48, bar: false, product_id: undefined, color_id: 14, date: "2016-11-30"}); + this.data.foo.records.push({id: 14, foo: -50, bar: true, product_id: 41, color_id: 14, date: "2016-12-15"}); + this.data.foo.records.push({id: 15, foo: 53, bar: false, product_id: 41, color_id: 14, date: "2016-11-01"}); + this.data.foo.records.push({id: 16, foo: 53, bar: true, product_id: undefined, color_id: 7, date: "2016-09-01"}); + this.data.foo.records.push({id: 17, foo: 48, bar: false, product_id: 41, color_id: undefined, date: "2016-08-01"}); + this.data.foo.records.push({id: 18, foo: -156, bar: false, product_id: 37, color_id: undefined, date: "2016-07-15"}); + this.data.foo.records.push({id: 19, foo: 31, bar: false, product_id: 41, color_id: 14, date: "2016-12-15"}); + this.data.foo.records.push({id: 20, foo: 109, bar: true, product_id: 41, color_id: 7, date: "2015-06-01"}); + + this.data.foo.records = sortBy(this.data.foo.records, 'date'); + + this.unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + + const graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: ` + <graph string="Partners" type="bar"> + <field name="foo" type="measure"/> + </graph> + `, + archs: { + 'foo,false,search': ` + <search> + <filter name="date" string="Date" context="{'group_by': 'date'}"/> + <filter name="date_filter" string="Date Filter" date="date"/> + <filter name="bar" string="Bar" context="{'group_by': 'bar'}"/> + <filter name="product_id" string="Product" context="{'group_by': 'product_id'}"/> + <filter name="color_id" string="Color" context="{'group_by': 'color_id'}"/> + </search> + `, + }, + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + + this.graph = graph; + + var checkOnlyToCheck = true; + var exhaustiveTest = false || checkOnlyToCheck; + + var self = this; + async function* graphGenerator(combinations) { + var i = 0; + while (i < combinations.length) { + var combination = combinations[i]; + if (!checkOnlyToCheck || combination.toString() in self.combinationsToCheck) { + await self.setConfig(combination); + } + if (exhaustiveTest) { + i++; + } else { + i += Math.floor(1 + Math.random() * 20); + } + yield combination; + } + } + + this.combinationsToCheck = {}; + this.testCombinations = async function (combinations, assert) { + for await (var combination of graphGenerator(combinations)) { + // we can check particular combinations here + if (combination.toString() in self.combinationsToCheck) { + if (self.combinationsToCheck[combination].errorMessage) { + assert.strictEqual( + graph.$('.o_nocontent_help p').eq(1).text().trim(), + self.combinationsToCheck[combination].errorMessage + ); + } else { + assert.checkLabels(graph, self.combinationsToCheck[combination].labels); + assert.checkLegend(graph, self.combinationsToCheck[combination].legend); + assert.checkDatasets(graph, ['label', 'data'], self.combinationsToCheck[combination].datasets); + } + } + } + }; + + const GROUPBY_NAMES = ['Date', 'Bar', 'Product', 'Color']; + + this.selectTimeRanges = async function (comparisonOptionId, basicDomainId) { + const facetEls = graph.el.querySelectorAll('.o_searchview_facet'); + const facetIndex = [...facetEls].findIndex(el => !!el.querySelector('span.fa-filter')); + if (facetIndex > -1) { + await cpHelpers.removeFacet(graph, facetIndex); + } + const [yearId, otherId] = basicDomainId.split('__'); + await cpHelpers.toggleFilterMenu(graph); + await cpHelpers.toggleMenuItem(graph, 'Date Filter'); + await cpHelpers.toggleMenuItemOption(graph, 'Date Filter', GENERATOR_INDEXES[yearId]); + if (otherId) { + await cpHelpers.toggleMenuItemOption(graph, 'Date Filter', GENERATOR_INDEXES[otherId]); + } + const itemIndex = COMPARISON_OPTION_INDEXES[comparisonOptionId]; + await cpHelpers.toggleComparisonMenu(graph); + await cpHelpers.toggleMenuItem(graph, itemIndex); + }; + + // groupby menu is assumed to be closed + this.selectDateIntervalOption = async function (intervalOption) { + intervalOption = intervalOption || 'month'; + const optionIndex = INTERVAL_OPTION_IDS.indexOf(intervalOption); + + await cpHelpers.toggleGroupByMenu(graph); + let wasSelected = false; + if (this.keepFirst) { + if (cpHelpers.isItemSelected(graph, 2)) { + wasSelected = true; + await cpHelpers.toggleMenuItem(graph, 2); + } + } + await cpHelpers.toggleMenuItem(graph, 0); + if (!cpHelpers.isOptionSelected(graph, 0, optionIndex)) { + await cpHelpers.toggleMenuItemOption(graph, 0, optionIndex); + } + for (let i = 0; i < INTERVAL_OPTION_IDS.length; i++) { + const oId = INTERVAL_OPTION_IDS[i]; + if (oId !== intervalOption && cpHelpers.isOptionSelected(graph, 0, i)) { + await cpHelpers.toggleMenuItemOption(graph, 0, i); + } + } + + if (this.keepFirst) { + if (wasSelected && !cpHelpers.isItemSelected(graph, 2)) { + await cpHelpers.toggleMenuItem(graph, 2); + } + } + await cpHelpers.toggleGroupByMenu(graph); + + }; + + // groupby menu is assumed to be closed + this.selectGroupBy = async function (groupByName) { + await cpHelpers.toggleGroupByMenu(graph); + const index = GROUPBY_NAMES.indexOf(groupByName); + if (!cpHelpers.isItemSelected(graph, index)) { + await cpHelpers.toggleMenuItem(graph, index); + } + await cpHelpers.toggleGroupByMenu(graph); + }; + + this.setConfig = async function (combination) { + await this.selectTimeRanges(combination[0], combination[1]); + if (combination.length === 3) { + await self.selectDateIntervalOption(combination[2]); + } + }; + + this.setMode = async function (mode) { + await testUtils.dom.click($(`.o_control_panel .o_graph_button[data-mode="${mode}"]`)); + }; + + }, + afterEach: function () { + this.unpatchDate(); + this.graph.destroy(); + }, + }, function () { + QUnit.test('comparison with one groupby equal to comparison date field', async function (assert) { + assert.expect(11); + + this.combinationsToCheck = { + 'previous_period,this_year__this_month,day': { + labels: [...Array(6).keys()].map(x => [x]), + legend: ["December 2016", "November 2016"], + datasets: [ + { + data: [110, 48, 26, 53, 63, 4], + label: "December 2016", + }, + { + data: [53, 24, 2, 48], + label: "November 2016", + } + ], + } + }; + + var combinations = COMBINATIONS_WITH_DATE; + await this.testCombinations(combinations, assert); + await this.setMode('line'); + await this.testCombinations(combinations, assert); + this.combinationsToCheck['previous_period,this_year__this_month,day'] = { + labels: [...Array(6).keys()].map(x => [x]), + legend: [ + "2016-12-01,2016-11-01", + "2016-12-10,2016-11-03", + "2016-12-15,2016-11-22", + "2016-12-17,2016-11-30", + "2016-12-19", + "2016-12-20" + ], + datasets: [ + { + data: [ 110, 48, 26, 53, 63, 4], + label: "December 2016", + }, + { + data: [ 53, 24, 2, 48, 0, 0], + label: "November 2016", + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + // isNotVisible can not have two elements so checking visibility of first element + assert.isNotVisible(this.graph.$('button[data-order]:first'), + "there should not be order button in comparison mode"); + assert.ok(true, "No combination causes a crash"); + }); + + QUnit.test('comparison with no groupby', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'previous_period,this_year__this_month': { + labels: [[]], + legend: ["December 2016", "November 2016"], + datasets: [ + { + data: [304], + label: "December 2016", + }, + { + data: [127], + label: "November 2016", + } + ], + } + }; + + var combinations = COMBINATIONS; + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_period,this_year__this_month'] = { + labels: [[''], [], ['']], + legend: ["December 2016", "November 2016"], + datasets: [ + { + data: [undefined, 304], + label: "December 2016", + }, + { + data: [undefined, 127], + label: "November 2016", + } + ], + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_period,this_year__this_month'] = { + labels: [[]], + legend: ["Total"], + datasets: [ + { + data: [304], + label: "December 2016", + }, + { + data: [127], + label: "November 2016", + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + + QUnit.test('comparison with one groupby different from comparison date field', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'previous_period,this_year__this_month': { + labels: [["xpad"], ["xphone"],["Undefined"]], + legend: ["December 2016", "November 2016"], + datasets: [ + { + data: [ 155, 149, 0], + label: "December 2016", + }, + { + data: [ 53, 26, 48], + label: "November 2016", + } + ], + } + }; + + var combinations = COMBINATIONS; + await this.selectGroupBy('Product'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_period,this_year__this_month'] = { + labels: [["xpad"], ["xphone"]], + legend: ["December 2016", "November 2016"], + datasets: [ + { + data: [155, 149], + label: "December 2016", + }, + { + data: [53, 26], + label: "November 2016", + } + ], + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_period,this_year__this_month'] = { + labels: [["xpad"], ["xphone"], ["Undefined"]], + legend: ["xpad", "xphone", "Undefined"], + datasets: [ + { + data: [ 155, 149, 0], + label: "December 2016", + }, + { + data: [ 53, 26, 48], + label: "November 2016", + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + + QUnit.test('comparison with two groupby with first groupby equal to comparison date field', async function (assert) { + assert.expect(10); + + this.keepFirst = true; + this.combinationsToCheck = { + 'previous_period,this_year__this_month,day': { + labels: [...Array(6).keys()].map(x => [x]), + legend: [ + "December 2016/xpad", + "December 2016/xphone", + "November 2016/xpad", + "November 2016/xphone", + "November 2016/Undefined" + ], + datasets: [ + { + data: [ 65, 0, 23, 0, 63, 4], + label: "December 2016/xpad" + }, + { + data: [ 45, 48, 3, 53, 0, 0], + label: "December 2016/xphone" + }, + { + data: [ 53, 0, 0, 0], + label: "November 2016/xpad" + }, + { + data: [ 0, 24, 2, 0], + label: "November 2016/xphone" + }, + { + data: [ 0, 0, 0, 48], + label: "November 2016/Undefined" + } + ] + } + }; + + var combinations = COMBINATIONS_WITH_DATE; + await this.selectGroupBy('Product'); + await this.testCombinations(combinations, assert); + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + + this.combinationsToCheck['previous_period,this_year__this_month,day'] = { + labels: [[0, "xpad"], [0, "xphone"], [1, "xphone"], [2, "xphone"], [2, "xpad"], [3, "xphone"], [4, "xpad"], [5, "xpad"], [3, "Undefined"]], + legend: [ + "2016-12-01,2016-11-01/xpad", + "2016-12-01,2016-11-01/xphone", + "2016-12-10,2016-11-03/xphone", + "2016-12-15,2016-11-22/xphone", + "2016-12-15,2016-11-22/xpad", + "2016-12-17,2016-11-30/xphone", + "2016-12-19/xpad", + "2016-12-20/xpad", + "2016-12-17,2016-11-30/Undefine..." + ], + datasets: [ + { + "data": [ 65, 45, 48, 3, 23, 53, 63, 4, 0], + "label": "December 2016" + }, + { + "data": [ 53, 0, 24, 2, 0, 0, 0, 0, 48], + "label": "November 2016" + } + ], + }; + + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + + this.keepFirst = false; + }); + + QUnit.test('comparison with two groupby with second groupby equal to comparison date field', async function (assert) { + assert.expect(8); + + this.combinationsToCheck = { + 'previous_period,this_year,quarter': { + labels: [["xphone"], ["xpad"],["Undefined"]], + legend: [ + "2016/Q3 2016", + "2016/Q4 2016", + "2015/Q2 2015" + ], + datasets: [ + { + data: [-156, 48, 53], + label: "2016/Q3 2016", + }, + { + data: [175, 208, 48], + label: "2016/Q4 2016", + }, + { + data: [0, 109, 0], + label: "2015/Q2 2015", + }, + ] + } + }; + + const combinations = COMBINATIONS_WITH_DATE; + await this.selectGroupBy('Product'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_period,this_year,quarter'] = { + labels: [["xphone"], ["xpad"]], + legend: [ + "2016/Q3 2016", + "2016/Q4 2016", + "2015/Q2 2015" + ], + datasets: [ + { + data: [-156, 48], + label: "2016/Q3 2016", + }, + { + data: [175, 208], + label: "2016/Q4 2016", + }, + { + data: [0, 109], + label: "2015/Q2 2015", + }, + ] + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_period,this_year,quarter'] = { + errorMessage: 'Pie chart cannot mix positive and negative numbers. ' + + 'Try to change your domain to only display positive results' + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + QUnit.test('comparison with two groupby with no groupby equal to comparison date field', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'previous_year,this_year__last_month': { + labels: [["xpad"], ["xphone"],["Undefined"] ], + legend: ["November 2016/false", "November 2016/true"], + datasets: [ + { + data: [53, 24, 48], + label: "November 2016/false", + }, + { + data: [0, 2, 0], + label: "November 2016/true", + } + ], + } + }; + + var combinations = COMBINATIONS; + await this.selectGroupBy('Product'); + await this.selectGroupBy('Bar'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_year,this_year__last_month'] = { + labels: [["xpad"], ["xphone"] ], + legend: ["November 2016/false", "November 2016/true"], + datasets: [ + { + data: [53, 24], + label: "November 2016/false", + }, + { + data: [0, 2], + label: "November 2016/true", + } + ], + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['previous_year,this_year__last_month'] = { + labels: + [["xpad", false], ["xphone", false], ["xphone", true], ["Undefined", false], ["No data"]], + legend: [ + "xpad/false", + "xphone/false", + "xphone/true", + "Undefined/false", + "No data" + ], + datasets: [ + { + "data": [ 53, 24, 2, 48], + "label": "November 2016" + }, + { + "data": [ undefined, undefined, undefined, undefined, 1], + "label": "November 2015" + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + }); +}); +}); diff --git a/addons/web/static/tests/views/kanban_benchmarks.js b/addons/web/static/tests/views/kanban_benchmarks.js new file mode 100644 index 00000000..020067cb --- /dev/null +++ b/addons/web/static/tests/views/kanban_benchmarks.js @@ -0,0 +1,92 @@ +odoo.define('web.kanban_benchmarks', function (require) { + "use strict"; + + const KanbanView = require('web.KanbanView'); + const { createView } = require('web.test_utils'); + + QUnit.module('Kanban View', { + beforeEach: function () { + this.data = { + foo: { + fields: { + foo: {string: "Foo", type: "char"}, + bar: {string: "Bar", type: "boolean"}, + int_field: {string: "int_field", type: "integer", sortable: true}, + qux: {string: "my float", type: "float"}, + }, + records: [ + { id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4}, + {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13}, + ] + }, + }; + this.arch = null; + this.run = function (assert) { + const data = this.data; + const arch = this.arch; + return new Promise(resolve => { + new Benchmark.Suite({}) + .add('kanban', { + defer: true, + fn: async (deferred) => { + const kanban = await createView({ + View: KanbanView, + model: 'foo', + data, + arch, + }); + kanban.destroy(); + deferred.resolve(); + }, + }) + .on('cycle', event => { + assert.ok(true, String(event.target)); + }) + .on('complete', resolve) + .run({ async: true }); + }); + }; + } + }, function () { + QUnit.test('simple kanban view with 2 records', function (assert) { + assert.expect(1); + + this.arch = ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <t t-esc="record.foo.value"/> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`; + return this.run(assert); + }); + + QUnit.test('simple kanban view with 200 records', function (assert) { + assert.expect(1); + + for (let i = 2; i < 200; i++) { + this.data.foo.records.push({ + id: i, + foo: `automated data ${i}`, + }); + } + + this.arch = ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <t t-esc="record.foo.value"/> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`; + return this.run(assert); + }); + }); +}); diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js new file mode 100644 index 00000000..2598f2bd --- /dev/null +++ b/addons/web/static/tests/views/kanban_model_tests.js @@ -0,0 +1,380 @@ +odoo.define('web.kanban_model_tests', function (require) { +"use strict"; + +var KanbanModel = require('web.KanbanModel'); +var testUtils = require('web.test_utils'); + +var createModel = testUtils.createModel; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + active: {string: "Active", type: "boolean", default: true}, + display_name: {string: "STRING", type: 'char'}, + foo: {string: "Foo", type: 'char'}, + bar: {string: "Bar", type: 'integer'}, + qux: {string: "Qux", type: 'many2one', relation: 'partner'}, + product_id: {string: "Favorite product", type: 'many2one', relation: 'product'}, + product_ids: {string: "Favorite products", type: 'one2many', relation: 'product'}, + category: {string: "Category M2M", type: 'many2many', relation: 'partner_type'}, + }, + records: [ + {id: 1, foo: 'blip', bar: 1, product_id: 37, category: [12], display_name: "first partner"}, + {id: 2, foo: 'gnap', bar: 2, product_id: 41, display_name: "second partner"}, + ], + onchanges: {}, + }, + product: { + fields: { + name: {string: "Product Name", type: "char"} + }, + records: [ + {id: 37, display_name: "xphone"}, + {id: 41, display_name: "xpad"} + ] + }, + partner_type: { + fields: { + display_name: {string: "Partner Type", type: "char"} + }, + records: [ + {id: 12, display_name: "gold"}, + {id: 14, display_name: "silver"}, + {id: 15, display_name: "bronze"} + ] + }, + }; + + // add related fields to category. + this.data.partner.fields.category.relatedFields = + $.extend(true, {}, this.data.partner_type.fields); + this.params = { + fields: this.data.partner.fields, + limit: 40, + modelName: 'partner', + openGroupByDefault: true, + viewType: 'kanban', + }; + }, +}, function () { + + QUnit.module('KanbanModel'); + + QUnit.test('load grouped + add a new group', async function (assert) { + var done = assert.async(); + assert.expect(22); + + var calledRoutes = {}; + var model = await createModel({ + Model: KanbanModel, + data: this.data, + mockRPC: function (route) { + if (!(route in calledRoutes)) { + calledRoutes[route] = 1; + } else { + calledRoutes[route]++; + } + return this._super.apply(this, arguments); + }, + }); + + var params = _.extend(this.params, { + groupedBy: ['product_id'], + fieldNames: ['foo'], + }); + + model.load(params).then(async function (resultID) { + // various checks on the load result + var state = model.get(resultID); + assert.ok(_.isEqual(state.groupedBy, ['product_id']), 'should be grouped by "product_id"'); + assert.strictEqual(state.data.length, 2, 'should have found 2 groups'); + assert.strictEqual(state.count, 2, 'both groups contain one record'); + var xphoneGroup = _.findWhere(state.data, {res_id: 37}); + assert.strictEqual(xphoneGroup.model, 'partner', 'group should have correct model'); + assert.ok(xphoneGroup, 'should have a group for res_id 37'); + assert.ok(xphoneGroup.isOpen, '"xphone" group should be open'); + assert.strictEqual(xphoneGroup.value, 'xphone', 'group 37 should be "xphone"'); + assert.strictEqual(xphoneGroup.count, 1, '"xphone" group should have one record'); + assert.strictEqual(xphoneGroup.data.length, 1, 'should have fetched the records in the group'); + assert.ok(_.isEqual(xphoneGroup.domain[0], ['product_id', '=', 37]), + 'domain should be correct'); + assert.strictEqual(xphoneGroup.limit, 40, 'limit in a group should be 40'); + + // add a new group + await model.createGroup('xpod', resultID); + state = model.get(resultID); + assert.strictEqual(state.data.length, 3, 'should now have 3 groups'); + assert.strictEqual(state.count, 2, 'there are still 2 records'); + var xpodGroup = _.findWhere(state.data, {value: 'xpod'}); + assert.strictEqual(xpodGroup.model, 'partner', 'new group should have correct model'); + assert.ok(xpodGroup, 'should have an "xpod" group'); + assert.ok(xpodGroup.isOpen, 'new group should be open'); + assert.strictEqual(xpodGroup.count, 0, 'new group should contain no record'); + assert.ok(_.isEqual(xpodGroup.domain[0], ['product_id', '=', xpodGroup.res_id]), + 'new group should have correct domain'); + + // check the rpcs done + assert.strictEqual(Object.keys(calledRoutes).length, 3, 'three different routes have been called'); + var nbReadGroups = calledRoutes['/web/dataset/call_kw/partner/web_read_group']; + var nbSearchRead = calledRoutes['/web/dataset/search_read']; + var nbNameCreate = calledRoutes['/web/dataset/call_kw/product/name_create']; + assert.strictEqual(nbReadGroups, 1, 'should have done 1 read_group'); + assert.strictEqual(nbSearchRead, 2, 'should have done 2 search_read'); + assert.strictEqual(nbNameCreate, 1, 'should have done 1 name_create'); + model.destroy(); + done(); + }); + }); + + QUnit.test('archive/restore a column', async function (assert) { + var done = assert.async(); + assert.expect(4); + + var model = await createModel({ + Model: KanbanModel, + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/action_archive') { + this.data.partner.records[0].active = false; + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + var params = _.extend(this.params, { + groupedBy: ['product_id'], + fieldNames: ['foo'], + }); + + model.load(params).then(async function (resultID) { + var state = model.get(resultID); + var xphoneGroup = _.findWhere(state.data, {res_id: 37}); + var xpadGroup = _.findWhere(state.data, {res_id: 41}); + assert.strictEqual(xphoneGroup.count, 1, 'xphone group has one record'); + assert.strictEqual(xpadGroup.count, 1, 'xpad group has one record'); + + // archive the column 'xphone' + var recordIDs = xphoneGroup.data.map(record => record.res_id); + await model.actionArchive(recordIDs, xphoneGroup.id); + state = model.get(resultID); + xphoneGroup = _.findWhere(state.data, {res_id: 37}); + assert.strictEqual(xphoneGroup.count, 0, 'xphone group has no record anymore'); + xpadGroup = _.findWhere(state.data, {res_id: 41}); + assert.strictEqual(xpadGroup.count, 1, 'xpad group still has one record'); + model.destroy(); + done(); + }); + }); + + QUnit.test('kanban model does not allow nested groups', async function (assert) { + var done = assert.async(); + assert.expect(2); + + var model = await createModel({ + Model: KanbanModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + assert.deepEqual(args.kwargs.groupby, ['product_id'], + "the second level of groupBy should have been removed"); + } + return this._super.apply(this, arguments); + }, + }); + + var params = _.extend(this.params, { + groupedBy: ['product_id', 'qux'], + fieldNames: ['foo'], + }); + + model.load(params).then(function (resultID) { + var state = model.get(resultID); + + assert.deepEqual(state.groupedBy, ['product_id'], + "the second level of groupBy should have been removed"); + + model.destroy(); + done(); + }); + }); + + QUnit.test('resequence columns and records', async function (assert) { + var done = assert.async(); + assert.expect(8); + + this.data.product.fields.sequence = {string: "Sequence", type: "integer"}; + this.data.partner.fields.sequence = {string: "Sequence", type: "integer"}; + this.data.partner.records.push({id: 3, foo: 'aaa', product_id: 37}); + + var nbReseq = 0; + var model = await createModel({ + Model: KanbanModel, + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + nbReseq++; + if (nbReseq === 1) { // resequencing columns + assert.deepEqual(args.ids, [41, 37], + "ids should be correct"); + assert.strictEqual(args.model, 'product', + "model should be correct"); + } else if (nbReseq === 2) { // resequencing records + assert.deepEqual(args.ids, [3, 1], + "ids should be correct"); + assert.strictEqual(args.model, 'partner', + "model should be correct"); + } + } + return this._super.apply(this, arguments); + }, + }); + var params = _.extend(this.params, { + groupedBy: ['product_id'], + fieldNames: ['foo'], + }); + + model.load(params) + .then(function (stateID) { + var state = model.get(stateID); + assert.strictEqual(state.data[0].res_id, 37, + "first group should be res_id 37"); + + // resequence columns + return model.resequence('product', [41, 37], stateID); + }) + .then(function (stateID) { + var state = model.get(stateID); + assert.strictEqual(state.data[0].res_id, 41, + "first group should be res_id 41 after resequencing"); + assert.strictEqual(state.data[1].data[0].res_id, 1, + "first record should be res_id 1"); + + // resequence records + return model.resequence('partner', [3, 1], state.data[1].id); + }) + .then(function (groupID) { + var group = model.get(groupID); + assert.strictEqual(group.data[0].res_id, 3, + "first record should be res_id 3 after resequencing"); + + model.destroy(); + done(); + }); + }); + + QUnit.test('add record to group', async function (assert) { + var done = assert.async(); + assert.expect(8); + + var self = this; + var model = await createModel({ + Model: KanbanModel, + data: this.data, + }); + var params = _.extend(this.params, { + groupedBy: ['product_id'], + fieldNames: ['foo'], + }); + + model.load(params).then(function (stateID) { + self.data.partner.records.push({id: 3, foo: 'new record', product_id: 37}); + + var state = model.get(stateID); + assert.deepEqual(state.res_ids, [1, 2], + "state should have the correct res_ids"); + assert.strictEqual(state.count, 2, + "state should have the correct count"); + assert.strictEqual(state.data[0].count, 1, + "first group should contain one record"); + + return model.addRecordToGroup(state.data[0].id, 3).then(function () { + var state = model.get(stateID); + assert.deepEqual(state.res_ids, [3, 1, 2], + "state should have the correct res_ids"); + assert.strictEqual(state.count, 3, + "state should have the correct count"); + assert.deepEqual(state.data[0].res_ids, [3, 1], + "new record's id should have been added to the res_ids"); + assert.strictEqual(state.data[0].count, 2, + "first group should now contain two records"); + assert.strictEqual(state.data[0].data[0].data.foo, 'new record', + "new record should have been fetched"); + }); + }).then(function() { + model.destroy(); + done(); + }) + + }); + + QUnit.test('call get (raw: true) before loading x2many data', async function (assert) { + // Sometimes, get can be called on a datapoint that is currently being + // reloaded, and thus in a partially updated state (e.g. in a kanban + // view, the user interacts with the searchview, and before the view is + // fully reloaded, it clicks on CREATE). Ideally, this shouldn't happen, + // but with the sync API of get, we can't change that easily. So at most, + // we can ensure that it doesn't crash. Moreover, sensitive functions + // requesting the state for more precise information that, e.g., the + // count, can do that in the mutex to ensure that the state isn't + // currently being reloaded. + // In this test, we have a grouped kanban view with a one2many, whose + // relational data is loaded in batch, once for all groups. We call get + // when the search_read for the first group has returned, but not the + // second (and thus, the read of the one2many hasn't started yet). + // Note: this test can be removed as soon as search_reads are performed + // alongside read_group. + var done = assert.async(); + assert.expect(2); + + this.data.partner.records[1].product_ids = [37, 41]; + this.params.fieldsInfo = { + kanban: { + product_ids: { + fieldsInfo: { + default: { display_name: {}, color: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + }, + }; + this.params.viewType = 'kanban'; + this.params.groupedBy = ['foo']; + + var block; + var def = testUtils.makeTestPromise(); + var model = await createModel({ + Model: KanbanModel, + data: this.data, + mockRPC: function (route) { + var result = this._super.apply(this, arguments); + if (route === '/web/dataset/search_read' && block) { + block = false; + return Promise.all([def]).then(_.constant(result)); + } + return result; + }, + }); + + model.load(this.params).then(function (handle) { + block = true; + model.reload(handle, {}); + + var state = model.get(handle, {raw: true}); + assert.strictEqual(state.count, 2); + + def.resolve(); + + state = model.get(handle, {raw: true}); + assert.strictEqual(state.count, 2); + }).then(function() { + model.destroy(); + done(); + }); + }); +}); + +}); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js new file mode 100644 index 00000000..1197fc9a --- /dev/null +++ b/addons/web/static/tests/views/kanban_tests.js @@ -0,0 +1,7248 @@ +odoo.define('web.kanban_tests', function (require) { +"use strict"; + +var AbstractField = require('web.AbstractField'); +const Domain = require('web.Domain'); +var fieldRegistry = require('web.field_registry'); +const FormRenderer = require("web.FormRenderer"); +var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); +var kanbanExamplesRegistry = require('web.kanban_examples_registry'); +var KanbanRenderer = require('web.KanbanRenderer'); +var KanbanView = require('web.KanbanView'); +var mixins = require('web.mixins'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); +var widgetRegistry = require('web.widget_registry'); + +var makeTestPromise = testUtils.makeTestPromise; +var nextTick = testUtils.nextTick; +const cpHelpers = testUtils.controlPanel; +var createView = testUtils.createView; + +QUnit.module('Views', { + before: function () { + this._initialKanbanProgressBarAnimate = KanbanColumnProgressBar.prototype.ANIMATE; + KanbanColumnProgressBar.prototype.ANIMATE = false; + }, + after: function () { + KanbanColumnProgressBar.prototype.ANIMATE = this._initialKanbanProgressBarAnimate; + }, + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + bar: {string: "Bar", type: "boolean"}, + int_field: {string: "int_field", type: "integer", sortable: true}, + qux: {string: "my float", type: "float"}, + product_id: {string: "something_id", type: "many2one", relation: "product"}, + category_ids: { string: "categories", type: "many2many", relation: 'category'}, + state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]}, + date: {string: "Date Field", type: 'date'}, + datetime: {string: "Datetime Field", type: 'datetime'}, + image: {string: "Image", type: "binary"}, + displayed_image_id: {string: "cover", type: "many2one", relation: "ir.attachment"}, + currency_id: {string: "Currency", type: "many2one", relation: "currency", default: 1}, + salary: {string: "Monetary field", type: "monetary"}, + }, + records: [ + {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: [], 'image': 'R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==', salary: 1750, currency_id: 1}, + {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6], salary: 1500, currency_id: 1}, + {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7], salary: 2000, currency_id: 2}, + {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: [], salary: 2222, currency_id: 1}, + ] + }, + product: { + fields: { + id: {string: "ID", type: "integer"}, + name: {string: "Display Name", type: "char"}, + }, + records: [ + {id: 3, name: "hello"}, + {id: 5, name: "xmo"}, + ] + }, + category: { + fields: { + name: {string: "Category Name", type: "char"}, + color: {string: "Color index", type: "integer"}, + }, + records: [ + {id: 6, name: "gold", color: 2}, + {id: 7, name: "silver", color: 5}, + ] + }, + 'ir.attachment': { + fields: { + mimetype: {type: "char"}, + name: {type: "char"}, + res_model: {type: "char"}, + res_id: {type: "integer"}, + }, + records: [ + {id: 1, name: "1.png", mimetype: 'image/png', res_model: 'partner', res_id: 1}, + {id: 2, name: "2.png", mimetype: 'image/png', res_model: 'partner', res_id: 2}, + ] + }, + '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'}, + ], + }, + }; + }, +}, function () { + + QUnit.module('KanbanView'); + + QUnit.test('basic ungrouped rendering', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div>' + + '<t t-esc="record.foo.value"/>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + assert.ok(args.context.bin_size, + "should not request direct binary payload"); + return this._super(route, args); + }, + }); + + assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_ungrouped'); + assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_test'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + assert.containsN(kanban,'.o_kanban_ghost', 6); + assert.containsOnce(kanban, '.o_kanban_record:contains(gnap)'); + kanban.destroy(); + }); + + QUnit.test('basic grouped rendering', async function (assert) { + assert.expect(13); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + // the lazy option is important, so the server can fill in + // the empty groups + assert.ok(args.kwargs.lazy, "should use lazy read_group"); + } + return this._super(route, args); + }, + }); + + assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); + assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_test'); + assert.containsN(kanban, '.o_kanban_group', 2); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3); + + // check available actions in kanban header's config dropdown + assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_kanban_toggle_fold'); + assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_edit'); + assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_delete'); + assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_archive_records'); + assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_unarchive_records'); + + // the next line makes sure that reload works properly. It looks useless, + // but it actually test that a grouped local record can be reloaded without + // changing its result. + await kanban.reload(kanban); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3); + + kanban.destroy(); + }); + + QUnit.test('basic grouped rendering with active field (archivable by default)', async function (assert) { + // var done = assert.async(); + assert.expect(9); + + // add active field on partner model and make all records active + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="active"/>' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/action_archive') { + var partnerIDS = args.args[0]; + var records = this.data.partner.records + _.each(partnerIDS, function(partnerID) { + _.find(records, function (record) { + return record.id === partnerID; + }).active = false; + }) + this.data.partner.records[0].active; + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + // check archive/restore all actions in kanban header's config dropdown + assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_archive_records'); + assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_unarchive_records'); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + // archive the records of the first column + assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3); + + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records')); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); + await testUtils.modal.clickButton('Cancel'); + assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3, "still last column should contain 3 records"); + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records')); + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + await testUtils.modal.clickButton('Ok'); + assert.containsNone(kanban, '.o_kanban_group:last .o_kanban_record', "last column should not contain any records"); + envIDs = [4]; + assert.deepEqual(kanban.exportState().resIds, envIDs); + kanban.destroy(); + }); + + QUnit.test('basic grouped rendering with active field and archive enabled (archivable true)', async function (assert) { + assert.expect(7); + + // add active field on partner model and make all records active + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" archivable="true">' + + '<field name="active"/>' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/action_archive') { + var partnerIDS = args.args[0]; + var records = this.data.partner.records + _.each(partnerIDS, function(partnerID) { + _.find(records, function (record) { + return record.id === partnerID; + }).active = false; + }) + this.data.partner.records[0].active; + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + // check archive/restore all actions in kanban header's config dropdown + assert.ok(kanban.$('.o_kanban_header:first .o_kanban_config .o_column_archive_records').length, "should be able to archive all the records"); + assert.ok(kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive_records').length, "should be able to restore all the records"); + + // archive the records of the first column + assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3, + "last column should contain 3 records"); + envIDs = [4]; + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records')); + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + await testUtils.modal.clickButton('Cancel'); // Click on 'Cancel' + assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3, "still last column should contain 3 records"); + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records')); + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + await testUtils.modal.clickButton('Ok'); // Click on 'Ok' + assert.containsNone(kanban, '.o_kanban_group:last .o_kanban_record', "last column should not contain any records"); + kanban.destroy(); + }); + + QUnit.test('basic grouped rendering with active field and hidden archive buttons (archivable false)', async function (assert) { + assert.expect(2); + + // add active field on partner model and make all records active + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" archivable="false">' + + '<field name="active"/>' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + // check archive/restore all actions in kanban header's config dropdown + assert.strictEqual( + kanban.$('.o_kanban_header:first .o_kanban_config .o_column_archive_records').length, 0, + "should not be able to archive all the records"); + assert.strictEqual( + kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive_records').length, 0, + "should not be able to restore all the records"); + kanban.destroy(); + }); + + QUnit.test('context can be used in kanban template', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div>' + + '<t t-if="context.some_key">' + + '<field name="foo"/>' + + '</t>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + context: {some_key: 1}, + domain: [['id', '=', 1]], + }); + + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1, + "there should be one record"); + assert.strictEqual(form.$('.o_kanban_record span:contains(yop)').length, 1, + "condition in the kanban template should have been correctly evaluated"); + + form.destroy(); + }); + + QUnit.test('pager should be hidden in grouped mode', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + assert.containsNone(kanban, '.o_pager'); + + kanban.destroy(); + }); + + QUnit.test('pager, ungrouped, with default limit', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + assert.strictEqual(args.limit, 40, "default limit should be 40 in Kanban"); + return this._super.apply(this, arguments); + }, + }); + + assert.containsOnce(kanban, '.o_pager'); + assert.strictEqual(cpHelpers.getPagerSize(kanban), "4", "pager's size should be 4"); + kanban.destroy(); + }); + + QUnit.test('pager, ungrouped, with limit given in options', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + assert.strictEqual(args.limit, 2, "limit should be 2"); + return this._super.apply(this, arguments); + }, + viewOptions: { + limit: 2, + }, + }); + + assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-2", "pager's limit should be 2"); + assert.strictEqual(cpHelpers.getPagerSize(kanban), "4", "pager's size should be 4"); + kanban.destroy(); + }); + + QUnit.test('pager, ungrouped, with limit set on arch and given in options', async function (assert) { + assert.expect(3); + + // the limit given in the arch should take the priority over the one given in options + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" limit="3">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + assert.strictEqual(args.limit, 3, "limit should be 3"); + return this._super.apply(this, arguments); + }, + viewOptions: { + limit: 2, + }, + }); + + assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-3", "pager's limit should be 3"); + assert.strictEqual(cpHelpers.getPagerSize(kanban), "4", "pager's size should be 4"); + kanban.destroy(); + }); + + QUnit.test('pager, ungrouped, deleting all records from last page should move to previous page', async function (assert) { + assert.expect(5); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + `<kanban class="o_kanban_test" limit="3"> + <templates> + <t t-name="kanban-box"> + <div> + <div><a role="menuitem" type="delete" class="dropdown-item">Delete</a></div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + }); + + assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-3", + "should have 3 records on current page"); + assert.strictEqual(cpHelpers.getPagerSize(kanban), "4", + "should have 4 records"); + + // move to next page + await cpHelpers.pagerNext(kanban); + assert.strictEqual(cpHelpers.getPagerValue(kanban), "4-4", + "should be on second page"); + + // delete a record + await testUtils.dom.click(kanban.$('.o_kanban_record:first a:first')); + await testUtils.dom.click($('.modal-footer button:first')); + assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-3", + "should have 1 page only"); + assert.strictEqual(cpHelpers.getPagerSize(kanban), "3", + "should have 4 records"); + + kanban.destroy(); + }); + + QUnit.test('create in grouped on m2o', async function (assert) { + assert.expect(5); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.hasClass(kanban.$('.o_kanban_view'),'ui-sortable', + "columns are sortable when grouped by a m2o field"); + assert.hasClass(kanban.$buttons.find('.o-kanban-button-new'),'btn-primary', + "'create' button should be btn-primary for grouped kanban with at least one column"); + assert.hasClass(kanban.$('.o_kanban_view > div:last'),'o_column_quick_create', + "column quick create should be enabled when grouped by a many2one field)"); + + await testUtils.kanban.clickCreate(kanban); // Click on 'Create' + assert.hasClass(kanban.$('.o_kanban_group:first() > div:nth(1)'),'o_kanban_quick_create', + "clicking on create should open the quick_create in the first column"); + + assert.ok(kanban.$('span.o_column_title:contains(hello)').length, + "should have a column title with a value from the many2one"); + kanban.destroy(); + }); + + QUnit.test('create in grouped on char', async function (assert) { + assert.expect(4); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + }); + + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'ui-sortable', + "columns aren't sortable when not grouped by a m2o field"); + assert.containsN(kanban, '.o_kanban_group', 3, "should have " + 3 + " columns"); + assert.strictEqual(kanban.$('.o_kanban_group:first() .o_column_title').text(), "yop", + "'yop' column should be the first column"); + assert.doesNotHaveClass(kanban.$('.o_kanban_view > div:last'), 'o_column_quick_create', + "column quick create should be disabled when not grouped by a many2one field)"); + kanban.destroy(); + }); + + QUnit.test('quick create record without quick_create_view', async function (assert) { + assert.expect(16); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'name_create') { + assert.strictEqual(args.args[0], 'new partner', + "should send the correct value"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain one record"); + + // click on 'Create' -> should open the quick create in the first column + await testUtils.kanban.clickCreate(kanban); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + + assert.strictEqual($quickCreate.length, 1, + "should have a quick create element in the first column"); + assert.strictEqual($quickCreate.find('.o_form_view.o_xxs_form_view').length, 1, + "should have rendered an XXS form view"); + assert.strictEqual($quickCreate.find('input').length, 1, + "should have only one input"); + assert.hasClass($quickCreate.find('input'), 'o_required_modifier', + "the field should be required"); + assert.strictEqual($quickCreate.find('input[placeholder=Title]').length, 1, + "input placeholder should be 'Title'"); + + // fill the quick create and validate + await testUtils.fields.editInput($quickCreate.find('input'), 'new partner'); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should contain two records"); + + assert.verifySteps([ + 'web_read_group', // initial read_group + '/web/dataset/search_read', // initial search_read (first column) + '/web/dataset/search_read', // initial search_read (second column) + 'onchange', // quick create + 'name_create', // should perform a name_create to create the record + 'read', // read the created record + 'onchange', // reopen the quick create automatically + ]); + + kanban.destroy(); + }); + + QUnit.test('quick create record with quick_create_view', async function (assert) { + assert.expect(19); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '<field name="state" widget="priority"/>' + + '</form>', + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'create') { + assert.deepEqual(args.args[0], { + foo: 'new partner', + int_field: 4, + state: 'def', + }, "should send the correct values"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsOnce(kanban, '.o_control_panel', 'should have one control panel'); + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain one record"); + + // click on 'Create' -> should open the quick create in the first column + await testUtils.kanban.clickCreate(kanban); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + + assert.strictEqual($quickCreate.length, 1, + "should have a quick create element in the first column"); + assert.strictEqual($quickCreate.find('.o_form_view.o_xxs_form_view').length, 1, + "should have rendered an XXS form view"); + assert.containsOnce(kanban, '.o_control_panel', 'should not have instantiated an extra control panel'); + assert.strictEqual($quickCreate.find('input').length, 2, + "should have two inputs"); + assert.strictEqual($quickCreate.find('.o_field_widget').length, 3, + "should have rendered three widgets"); + + // fill the quick create and validate + await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner'); + await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=int_field]'), '4'); + await testUtils.dom.click($quickCreate.find('.o_field_widget[name=state] .o_priority_star:first')); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should contain two records"); + + assert.verifySteps([ + 'web_read_group', // initial read_group + '/web/dataset/search_read', // initial search_read (first column) + '/web/dataset/search_read', // initial search_read (second column) + 'load_views', // form view in quick create + 'onchange', // quick create + 'create', // should perform a create to create the record + 'read', // read the created record + 'load_views', // form view in quick create (is actually in cache) + 'onchange', // reopen the quick create automatically + ]); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped on m2o (no quick_create_view)', async function (assert) { + assert.expect(12); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'name_create') { + assert.strictEqual(args.args[0], 'new partner', + "should send the correct value"); + assert.deepEqual(args.kwargs.context, { + default_product_id: 3, + default_qux: 2.5, + }, "should send the correct context"); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + context: {default_qux: 2.5}, + }, + }); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should contain two records"); + + // click on 'Create', fill the quick create and validate + await testUtils.kanban.clickCreate(kanban); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'new partner'); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3, + "first column should contain three records"); + + assert.verifySteps([ + 'web_read_group', // initial read_group + '/web/dataset/search_read', // initial search_read (first column) + '/web/dataset/search_read', // initial search_read (second column) + 'onchange', // quick create + 'name_create', // should perform a name_create to create the record + 'read', // read the created record + 'onchange', // reopen the quick create automatically + ]); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped on m2o (with quick_create_view)', async function (assert) { + assert.expect(14); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '<field name="state" widget="priority"/>' + + '</form>', + }, + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'create') { + assert.deepEqual(args.args[0], { + foo: 'new partner', + int_field: 4, + state: 'def', + }, "should send the correct values"); + assert.deepEqual(args.kwargs.context, { + default_product_id: 3, + default_qux: 2.5, + }, "should send the correct context"); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + context: {default_qux: 2.5}, + }, + }); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should contain two records"); + + // click on 'Create', fill the quick create and validate + await testUtils.kanban.clickCreate(kanban); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner'); + await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=int_field]'), '4'); + await testUtils.dom.click($quickCreate.find('.o_field_widget[name=state] .o_priority_star:first')); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3, + "first column should contain three records"); + + assert.verifySteps([ + 'web_read_group', // initial read_group + '/web/dataset/search_read', // initial search_read (first column) + '/web/dataset/search_read', // initial search_read (second column) + 'load_views', // form view in quick create + 'onchange', // quick create + 'create', // should perform a create to create the record + 'read', // read the created record + 'load_views', // form view in quick create (is actually in cache) + 'onchange', // reopen the quick create automatically + ]); + + kanban.destroy(); + }); + + QUnit.test('quick create record with default values and onchanges', async function (assert) { + assert.expect(10); + + this.data.partner.fields.int_field.default = 4; + this.data.partner.onchanges = { + foo: function (obj) { + if (obj.foo) { + obj.int_field = 8; + } + }, + }; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>', + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + // click on 'Create' -> should open the quick create in the first column + await testUtils.kanban.clickCreate(kanban); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + + assert.strictEqual($quickCreate.length, 1, + "should have a quick create element in the first column"); + assert.strictEqual($quickCreate.find('.o_field_widget[name=int_field]').val(), '4', + "default value should be set"); + + // fill the 'foo' field -> should trigger the onchange + await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner'); + + assert.strictEqual($quickCreate.find('.o_field_widget[name=int_field]').val(), '8', + "onchange should have been triggered"); + + assert.verifySteps([ + 'web_read_group', // initial read_group + '/web/dataset/search_read', // initial search_read (first column) + '/web/dataset/search_read', // initial search_read (second column) + 'load_views', // form view in quick create + 'onchange', // quick create + 'onchange', // onchange due to 'foo' field change + ]); + + kanban.destroy(); + }); + + QUnit.test('quick create record with quick_create_view: modifiers', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo" required="1"/>' + + '<field name="int_field" attrs=\'{"invisible": [["foo", "=", false]]}\'/>' + + '</form>', + }, + groupBy: ['bar'], + }); + + // create a new record + await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_quick_add')); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + + assert.hasClass($quickCreate.find('.o_field_widget[name=foo]'),'o_required_modifier', + "foo field should be required"); + assert.hasClass($quickCreate.find('.o_field_widget[name=int_field]'),'o_invisible_modifier', + "int_field should be invisible"); + + // fill 'foo' field + await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner'); + + assert.doesNotHaveClass($quickCreate.find('.o_field_widget[name=int_field]'), 'o_invisible_modifier', + "int_field should now be visible"); + + kanban.destroy(); + }); + + QUnit.test('quick create record and change state in grouped mode', async function (assert) { + assert.expect(1); + + this.data.partner.fields.kanban_state = { + string: "Kanban State", + type: "selection", + selection: [["normal", "Grey"], ["done", "Green"], ["blocked", "Red"]], + }; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '<div class="oe_kanban_bottom_right">' + + '<field name="kanban_state" widget="state_selection"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + }); + + // Quick create kanban record + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + var $quickAdd = kanban.$('.o_kanban_quick_create'); + $quickAdd.find('.o_input').val('Test'); + await testUtils.dom.click($quickAdd.find('.o_kanban_add')); + + // Select state in kanban + await testUtils.dom.click(kanban.$('.o_status').first()); + await testUtils.dom.click(kanban.$('.o_selection .dropdown-item:first')); + assert.hasClass(kanban.$('.o_status').first(),'o_status_green', + "Kanban state should be done (Green)"); + kanban.destroy(); + }); + + QUnit.test('window resize should not change quick create form size', async function (assert) { + assert.expect(2); + + testUtils.mock.patch(FormRenderer, { + start: function () { + this._super.apply(this, arguments); + window.addEventListener("resize", this._applyFormSizeClass.bind(this)); + }, + + }); + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + // click to add an element and cancel the quick creation by pressing ESC + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_header .o_kanban_quick_add i')); + + const quickCreate = kanban.el.querySelector('.o_kanban_quick_create'); + assert.hasClass(quickCreate.querySelector('.o_form_view'), "o_xxs_form_view"); + + // trigger window resize explicitly to call _applyFormSizeClass + window.dispatchEvent(new Event('resize')); + assert.hasClass(quickCreate.querySelector('.o_form_view'), 'o_xxs_form_view'); + + kanban.destroy(); + testUtils.mock.unpatch(FormRenderer); + }); + + QUnit.test('quick create record: cancel and validate without using the buttons', async function (assert) { + assert.expect(9); + + var nbRecords = 4; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + assert.strictEqual(kanban.exportState().resIds.length, nbRecords); + + // click to add an element and cancel the quick creation by pressing ESC + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + + var $quickCreate = kanban.$('.o_kanban_quick_create'); + assert.strictEqual($quickCreate.length, 1, "should have a quick create element"); + + $quickCreate.find('input').trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ESCAPE, + which: $.ui.keyCode.ESCAPE, + })); + assert.containsNone(kanban, '.o_kanban_quick_create', + "should have destroyed the quick create element"); + + // click to add and element and click outside, should cancel the quick creation + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first')); + assert.containsNone(kanban, '.o_kanban_quick_create', + "the quick create should be destroyed when the user clicks outside"); + + // click to input and drag the mouse outside, should not cancel the quick creation + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + $quickCreate = kanban.$('.o_kanban_quick_create'); + await testUtils.dom.triggerMouseEvent($quickCreate.find('input'), 'mousedown'); + await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should not have been destroyed after clicking outside"); + + // click to really add an element + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + $quickCreate = kanban.$('.o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'new partner'); + + // clicking outside should no longer destroy the quick create as it is dirty + await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first')); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should not have been destroyed"); + + // confirm by pressing ENTER + nbRecords = 5; + $quickCreate.find('input').trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ENTER, + which: $.ui.keyCode.ENTER, + })); + + await nextTick(); + assert.strictEqual(this.data.partner.records.length, 5, + "should have created a partner"); + assert.strictEqual(_.last(this.data.partner.records).name, "new partner", + "should have correct name"); + assert.strictEqual(kanban.exportState().resIds.length, nbRecords); + + kanban.destroy(); + }); + + QUnit.test('quick create record: validate with ENTER', async function (assert) { + // in this test, we accurately mock the behavior of the webclient by specifying a + // fieldDebounce > 0, meaning that the changes in an InputField aren't notified to the model + // on 'input' events, but they wait for the 'change' event (or a call to 'commitChanges', + // e.g. triggered by a navigation event) + // in this scenario, the call to 'commitChanges' actually does something (i.e. it notifies + // the new value of the char field), whereas it does nothing if the changes are notified + // directly + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>', + }, + groupBy: ['bar'], + fieldDebounce: 5000, + }); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should have 4 records at the beginning"); + + // add an element and confirm by pressing ENTER + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + await testUtils.kanban.quickCreate(kanban, 'new partner', 'foo'); + // triggers a navigation event, leading to the 'commitChanges' and record creation + + assert.containsN(kanban, '.o_kanban_record', 5, + "should have created a new record"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '', + "quick create should now be empty"); + + kanban.destroy(); + }); + + QUnit.test('quick create record: prevent multiple adds with ENTER', async function (assert) { + assert.expect(9); + + var prom = makeTestPromise(); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>', + }, + groupBy: ['bar'], + // add a fieldDebounce to accurately simulate what happens in the webclient: the field + // doesn't notify the BasicModel that it has changed directly, as it waits for the user + // to focusout or navigate (e.g. by pressing ENTER) + fieldDebounce: 5000, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'create') { + assert.step('create'); + return prom.then(function () { + return result; + }); + } + return result; + }, + }); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should have 4 records at the beginning"); + + // add an element and press ENTER twice + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + var enterEvent = { + keyCode: $.ui.keyCode.ENTER, + which: $.ui.keyCode.ENTER, + }; + await testUtils.fields.editAndTrigger( + kanban.$('.o_kanban_quick_create').find('input[name=foo]'), + 'new partner', + ['input', $.Event('keydown', enterEvent), $.Event('keydown', enterEvent)] + ); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should not have created the record yet"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner', + "quick create should not be empty yet"); + assert.hasClass(kanban.$('.o_kanban_quick_create'), 'o_disabled', + "quick create should be disabled"); + + prom.resolve(); + await nextTick(); + + assert.containsN(kanban, '.o_kanban_record', 5, + "should have created a new record"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '', + "quick create should now be empty"); + assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled', + "quick create should be enabled"); + + assert.verifySteps(['create']); + + kanban.destroy(); + }); + + QUnit.test('quick create record: prevent multiple adds with Add clicked', async function (assert) { + assert.expect(9); + + var prom = makeTestPromise(); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>', + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'create') { + assert.step('create'); + return prom.then(function () { + return result; + }); + } + return result; + }, + }); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should have 4 records at the beginning"); + + // add an element and click 'Add' twice + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create').find('input[name=foo]'), 'new partner'); + await testUtils.dom.click(kanban.$('.o_kanban_quick_create').find('.o_kanban_add')); + await testUtils.dom.click(kanban.$('.o_kanban_quick_create').find('.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should not have created the record yet"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner', + "quick create should not be empty yet"); + assert.hasClass(kanban.$('.o_kanban_quick_create'),'o_disabled', + "quick create should be disabled"); + + prom.resolve(); + + await nextTick(); + assert.containsN(kanban, '.o_kanban_record', 5, + "should have created a new record"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '', + "quick create should now be empty"); + assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled', + "quick create should be enabled"); + + assert.verifySteps(['create']); + + kanban.destroy(); + }); + + QUnit.test('quick create record: prevent multiple adds with ENTER, with onchange', async function (assert) { + assert.expect(13); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field += (obj.foo ? 3 : 0); + }, + }; + var shouldDelayOnchange = false; + var prom = makeTestPromise(); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>', + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + assert.step('onchange'); + if (shouldDelayOnchange) { + return Promise.resolve(prom).then(function () { + return result; + }); + } + } + if (args.method === 'create') { + assert.step('create'); + assert.deepEqual(_.pick(args.args[0], 'foo', 'int_field'), { + foo: 'new partner', + int_field: 3, + }); + } + return result; + }, + // add a fieldDebounce to accurately simulate what happens in the webclient: the field + // doesn't notify the BasicModel that it has changed directly, as it waits for the user + // to focusout or navigate (e.g. by pressing ENTER) + fieldDebounce: 5000, + }); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should have 4 records at the beginning"); + + // add an element and press ENTER twice + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + shouldDelayOnchange = true; + var enterEvent = { + keyCode: $.ui.keyCode.ENTER, + which: $.ui.keyCode.ENTER, + }; + + await testUtils.fields.editAndTrigger( + kanban.$('.o_kanban_quick_create').find('input[name=foo]'), + 'new partner', + ['input', $.Event('keydown', enterEvent), $.Event('keydown', enterEvent)] + ); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should not have created the record yet"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner', + "quick create should not be empty yet"); + assert.hasClass(kanban.$('.o_kanban_quick_create'),'o_disabled', + "quick create should be disabled"); + + prom.resolve(); + + await nextTick(); + assert.containsN(kanban, '.o_kanban_record', 5, + "should have created a new record"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '', + "quick create should now be empty"); + assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled', + "quick create should be enabled"); + + assert.verifySteps([ + 'onchange', // default_get + 'onchange', // new partner + 'create', + 'onchange', // default_get + ]); + + kanban.destroy(); + }); + + QUnit.test('quick create record: click Add to create, with delayed onchange', async function (assert) { + assert.expect(13); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field += (obj.foo ? 3 : 0); + }, + }; + var shouldDelayOnchange = false; + var prom = makeTestPromise(); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/><field name="int_field"/></div>' + + '</t></templates></kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>', + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + assert.step('onchange'); + if (shouldDelayOnchange) { + return Promise.resolve(prom).then(function () { + return result; + }); + } + } + if (args.method === 'create') { + assert.step('create'); + assert.deepEqual(_.pick(args.args[0], 'foo', 'int_field'), { + foo: 'new partner', + int_field: 3, + }); + } + return result; + }, + }); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should have 4 records at the beginning"); + + // add an element and click 'add' + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + shouldDelayOnchange = true; + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create').find('input[name=foo]'), 'new partner'); + await testUtils.dom.click(kanban.$('.o_kanban_quick_create').find('.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_record', 4, + "should not have created the record yet"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner', + "quick create should not be empty yet"); + assert.hasClass(kanban.$('.o_kanban_quick_create'),'o_disabled', + "quick create should be disabled"); + + prom.resolve(); // the onchange returns + + await nextTick(); + assert.containsN(kanban, '.o_kanban_record', 5, + "should have created a new record"); + assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '', + "quick create should now be empty"); + assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled', + "quick create should be enabled"); + + assert.verifySteps([ + 'onchange', // default_get + 'onchange', // new partner + 'create', + 'onchange', // default_get + ]); + + kanban.destroy(); + }); + + QUnit.test('quick create when first column is folded', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + assert.doesNotHaveClass(kanban.$('.o_kanban_group:first'), 'o_column_folded', + "first column should not be folded"); + + // fold the first column + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first')); + await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold')); + + assert.hasClass(kanban.$('.o_kanban_group:first'),'o_column_folded', + "first column should be folded"); + + // click on 'Create' to open the quick create in the first column + await testUtils.kanban.clickCreate(kanban); + + assert.doesNotHaveClass(kanban.$('.o_kanban_group:first'), 'o_column_folded', + "first column should no longer be folded"); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + assert.strictEqual($quickCreate.length, 1, + "should have added a quick create element in first column"); + + // fold again the first column + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first')); + await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold')); + + assert.hasClass(kanban.$('.o_kanban_group:first'),'o_column_folded', + "first column should be folded"); + assert.containsNone(kanban, '.o_kanban_quick_create', + "there should be no more quick create"); + + kanban.destroy(); + }); + + QUnit.test('quick create record: cancel when not dirty', async function (assert) { + assert.expect(11); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain one record"); + + // click to add an element + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have open the quick create widget"); + + // click again to add an element -> should have kept the quick create open + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have kept the quick create open"); + + // click outside: should remove the quick create + await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first')); + assert.containsNone(kanban, '.o_kanban_quick_create', + "the quick create should not have been destroyed"); + + // click to reopen the quick create + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have open the quick create widget"); + + // press ESC: should remove the quick create + kanban.$('.o_kanban_quick_create input').trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ESCAPE, + which: $.ui.keyCode.ESCAPE, + })); + assert.containsNone(kanban, '.o_kanban_quick_create', + "quick create widget should have been removed"); + + // click to reopen the quick create + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have open the quick create widget"); + + // click on 'Discard': should remove the quick create + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first')); + assert.containsNone(kanban, '.o_kanban_quick_create', + "the quick create should be destroyed when the user clicks outside"); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should still contain one record"); + + // click to reopen the quick create + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have open the quick create widget"); + + // clicking on the quick create itself should keep it open + await testUtils.dom.click(kanban.$('.o_kanban_quick_create')); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should not have been destroyed when clicked on itself"); + + + kanban.destroy(); + }); + + QUnit.test('quick create record: cancel when modal is opened', async function (assert) { + assert.expect(3); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="product_id"/>' + + '</form>', + }, + groupBy: ['bar'], + }); + + // click to add an element + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have open the quick create widget"); + + kanban.$('.o_kanban_quick_create input') + .val('test') + .trigger('keyup') + .trigger('focusout'); + await nextTick(); + + // When focusing out of the many2one, a modal to add a 'product' will appear. + // The following assertions ensures that a click on the body element that has 'modal-open' + // will NOT close the quick create. + // This can happen when the user clicks out of the input because of a race condition between + // the focusout of the m2o and the global 'click' handler of the quick create. + // Check odoo/odoo#61981 for more details. + const $body = kanban.$el.closest('body'); + assert.hasClass($body, 'modal-open', + "modal should be opening after m2o focusout"); + await testUtils.dom.click($body); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "quick create should stay open while modal is opening"); + + kanban.destroy(); + }); + + QUnit.test('quick create record: cancel when dirty', async function (assert) { + assert.expect(7); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain one record"); + + // click to add an element and edit it + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have open the quick create widget"); + + var $quickCreate = kanban.$('.o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'some value'); + + // click outside: should not remove the quick create + await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first')); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should not have been destroyed"); + + // press ESC: should remove the quick create + $quickCreate.find('input').trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ESCAPE, + which: $.ui.keyCode.ESCAPE, + })); + assert.containsNone(kanban, '.o_kanban_quick_create', + "quick create widget should have been removed"); + + // click to reopen quick create and edit it + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have open the quick create widget"); + + $quickCreate = kanban.$('.o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'some value'); + + // click on 'Discard': should remove the quick create + await testUtils.dom.click(kanban.$('.o_kanban_quick_create .o_kanban_cancel')); + assert.containsNone(kanban, '.o_kanban_quick_create', + "the quick create should be destroyed when the user clicks outside"); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should still contain one record"); + + kanban.destroy(); + }); + + QUnit.test('quick create record and edit in grouped mode', async function (assert) { + assert.expect(6); + + var newRecordID; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + var def = this._super.apply(this, arguments); + if (args.method === 'name_create') { + def.then(function (result) { + newRecordID = result[0]; + }); + } + return def; + }, + groupBy: ['bar'], + intercepts: { + switch_view: function (event) { + assert.strictEqual(event.data.mode, "edit", + "should trigger 'open_record' event in edit mode"); + assert.strictEqual(event.data.res_id, newRecordID, + "should open the correct record"); + }, + }, + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain one record"); + + // click to add and edit an element + var $quickCreate = kanban.$('.o_kanban_quick_create'); + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + $quickCreate = kanban.$('.o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'new partner'); + await testUtils.dom.click($quickCreate.find('button.o_kanban_edit')); + + assert.strictEqual(this.data.partner.records.length, 5, + "should have created a partner"); + assert.strictEqual(_.last(this.data.partner.records).name, "new partner", + "should have correct name"); + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should now contain two records"); + + kanban.destroy(); + }); + + QUnit.test('quick create several records in a row', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain one record"); + + // click to add an element, fill the input and press ENTER + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should be open"); + + await testUtils.kanban.quickCreate(kanban, 'new partner 1'); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should now contain two records"); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should still be open"); + + // create a second element in a row + await testUtils.kanban.quickCreate(kanban, 'new partner 2'); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3, + "first column should now contain three records"); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should still be open"); + + kanban.destroy(); + }); + + QUnit.test('quick create is disabled until record is created and read', async function (assert) { + assert.expect(6); + + var prom = makeTestPromise(); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'read') { + return prom.then(_.constant(result)); + } + return result; + }, + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain one record"); + + // click to add a record, and add two in a row (first one will be delayed) + await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); + + assert.containsOnce(kanban, '.o_kanban_quick_create', + "the quick create should be open"); + + await testUtils.kanban.quickCreate(kanban, 'new partner 1'); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should still contain one record"); + assert.containsOnce(kanban, '.o_kanban_quick_create.o_disabled', + "quick create should be disabled"); + + prom.resolve(); + + await nextTick(); + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should now contain two records"); + assert.strictEqual(kanban.$('.o_kanban_quick_create:not(.o_disabled)').length, 1, + "quick create should be enabled"); + + kanban.destroy(); + }); + + QUnit.test('quick create record fail in grouped by many2one', async function (assert) { + assert.expect(8); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,false,form': '<form string="Partner">' + + '<field name="product_id"/>' + + '<field name="foo"/>' + + '</form>', + }, + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'name_create') { + return Promise.reject({ + message: { + code: 200, + data: {}, + message: "Odoo server error", + }, + event: $.Event() + }); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "there should be 2 records in first column"); + + await testUtils.kanban.clickCreate(kanban); // Click on 'Create' + assert.hasClass(kanban.$('.o_kanban_group:first() > div:nth(1)'),'o_kanban_quick_create', + "clicking on create should open the quick_create in the first column"); + + await testUtils.kanban.quickCreate(kanban, 'test'); + + assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1, + "a form view dialog should have been opened (in edit)"); + assert.strictEqual($('.modal .o_field_many2one input').val(), 'hello', + "the correct product_id should already be set"); + + // specify a name and save + await testUtils.fields.editInput($('.modal input[name=foo]'), 'test'); + await testUtils.modal.clickButton('Save'); + + assert.strictEqual($('.modal').length, 0, "the modal should be closed"); + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3, + "there should be 3 records in first column"); + var $firstRecord = kanban.$('.o_kanban_group:first .o_kanban_record:first'); + assert.strictEqual($firstRecord.text(), 'test', + "the first record of the first column should be the new one"); + assert.strictEqual(kanban.$('.o_kanban_quick_create:not(.o_disabled)').length, 1, + "quick create should be enabled"); + + kanban.destroy(); + }); + + QUnit.test('quick create record is re-enabled after discard on failure', async function (assert) { + assert.expect(4); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,false,form': '<form string="Partner">' + + '<field name="product_id"/>' + + '<field name="foo"/>' + + '</form>', + }, + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'name_create') { + return Promise.reject({ + message: { + code: 200, + data: {}, + message: "Odoo server error", + }, + event: $.Event() + }); + } + return this._super.apply(this, arguments); + } + }); + + await testUtils.kanban.clickCreate(kanban); + assert.containsOnce(kanban, '.o_kanban_quick_create', + "should have a quick create widget"); + + await testUtils.kanban.quickCreate(kanban, 'test'); + + assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1, + "a form view dialog should have been opened (in edit)"); + + await testUtils.modal.clickButton('Discard'); + + assert.strictEqual($('.modal').length, 0, "the modal should be closed"); + assert.strictEqual(kanban.$('.o_kanban_quick_create:not(.o_disabled)').length, 1, + "quick create widget should have been re-enabled"); + + kanban.destroy(); + }); + + QUnit.test('quick create record fails in grouped by char', async function (assert) { + assert.expect(7); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,false,form': '<form>' + + '<field name="foo"/>' + + '</form>', + }, + mockRPC: function (route, args) { + if (args.method === 'name_create') { + return Promise.reject({ + message: { + code: 200, + data: {}, + message: "Odoo server error", + }, + event: $.Event() + }); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], {foo: 'yop'}, + "should write the correct value for foo"); + assert.deepEqual(args.kwargs.context, {default_foo: 'yop', default_name: 'test'}, + "should send the correct default value for foo"); + } + return this._super.apply(this, arguments); + }, + groupBy: ['foo'], + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "there should be 1 record in first column"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'test'); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1, + "a form view dialog should have been opened (in edit)"); + assert.strictEqual($('.modal .o_field_widget[name=foo]').val(), 'yop', + "the correct default value for foo should already be set"); + await testUtils.modal.clickButton('Save'); + + assert.strictEqual($('.modal').length, 0, "the modal should be closed"); + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "there should be 2 records in first column"); + + kanban.destroy(); + }); + + QUnit.test('quick create record fails in grouped by selection', async function (assert) { + assert.expect(7); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="state"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,false,form': '<form>' + + '<field name="state"/>' + + '</form>', + }, + mockRPC: function (route, args) { + if (args.method === 'name_create') { + return Promise.reject({ + message: { + code: 200, + data: {}, + message: "Odoo server error", + }, + event: $.Event() + }); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], {state: 'abc'}, + "should write the correct value for state"); + assert.deepEqual(args.kwargs.context, {default_state: 'abc', default_name: 'test'}, + "should send the correct default value for state"); + } + return this._super.apply(this, arguments); + }, + groupBy: ['state'], + }); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "there should be 1 record in first column"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'test'); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1, + "a form view dialog should have been opened (in edit)"); + assert.strictEqual($('.modal .o_field_widget[name=state]').val(), '"abc"', + "the correct default value for state should already be set"); + + await testUtils.modal.clickButton('Save'); + + assert.strictEqual($('.modal').length, 0, "the modal should be closed"); + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "there should be 2 records in first column"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in empty grouped kanban', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + var result = { + groups: [ + {__domain: [['product_id', '=', 3]], product_id_count: 0}, + {__domain: [['product_id', '=', 5]], product_id_count: 0}, + ], + length: 2, + }; + return Promise.resolve(result); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, + "there should be 2 columns"); + assert.containsNone(kanban, '.o_kanban_record', + "both columns should be empty"); + + await testUtils.kanban.clickCreate(kanban); + + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_quick_create', + "should have opened the quick create in the first column"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped on date(time) field', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['date'], + intercepts: { + switch_view: function (ev) { + assert.deepEqual(_.pick(ev.data, 'res_id', 'view_type'), { + res_id: undefined, + view_type: 'form', + }, "should trigger an event to open the form view (twice)"); + }, + }, + }); + + assert.containsNone(kanban, '.o_kanban_header .o_kanban_quick_add i', + "quick create should be disabled when grouped on a date field"); + + // clicking on CREATE in control panel should not open a quick create + await testUtils.kanban.clickCreate(kanban); + assert.containsNone(kanban, '.o_kanban_quick_create', + "should not have opened the quick create widget"); + + await kanban.reload({groupBy: ['datetime']}); + + assert.containsNone(kanban, '.o_kanban_header .o_kanban_quick_add i', + "quick create should be disabled when grouped on a datetime field"); + + // clicking on CREATE in control panel should not open a quick create + await testUtils.kanban.clickCreate(kanban); + assert.containsNone(kanban, '.o_kanban_quick_create', + "should not have opened the quick create widget"); + + kanban.destroy(); + }); + + QUnit.test('quick create record feature is properly enabled/disabled at reload', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + }); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3, + "quick create should be enabled when grouped on a char field"); + + await kanban.reload({groupBy: ['date']}); + + assert.containsNone(kanban, '.o_kanban_header .o_kanban_quick_add i', + "quick create should now be disabled (grouped on date field)"); + + await kanban.reload({groupBy: ['bar']}); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 2, + "quick create should be enabled again (grouped on boolean field)"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by char field', async function (assert) { + assert.expect(4); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + mockRPC: function (route, args) { + if (args.method === 'name_create') { + assert.deepEqual(args.kwargs.context, {default_foo: 'yop'}, + "should send the correct default value for foo"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3, + "quick create should be enabled when grouped on a char field"); + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain 1 record"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record'); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should now contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by boolean field', async function (assert) { + assert.expect(4); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + if (args.method === 'name_create') { + assert.deepEqual(args.kwargs.context, {default_bar: true}, + "should send the correct default value for bar"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 2, + "quick create should be enabled when grouped on a boolean field"); + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 3, + "second column (true) should contain 3 records"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:nth(1) .o_kanban_quick_add i')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record'); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 4, + "second column (true) should now contain 4 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped on selection field', async function (assert) { + assert.expect(4); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (args.method === 'name_create') { + assert.deepEqual(args.kwargs.context, {default_state: 'abc'}, + "should send the correct default value for bar"); + } + return this._super.apply(this, arguments); + }, + groupBy: ['state'], + }); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3, + "quick create should be enabled when grouped on a selection field"); + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column (abc) should contain 1 record"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record'); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column (abc) should contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by char field (within quick_create_view)', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '</form>', + }, + groupBy: ['foo'], + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0], {foo: 'yop'}, + "should write the correct value for foo"); + assert.deepEqual(args.kwargs.context, {default_foo: 'yop'}, + "should send the correct default value for foo"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3, + "quick create should be enabled when grouped on a char field"); + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column should contain 1 record"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i')); + assert.strictEqual(kanban.$('.o_kanban_quick_create input').val(), 'yop', + "should have set the correct foo value by default"); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column should now contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by boolean field (within quick_create_view)', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="bar"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="bar"/>' + + '</form>', + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0], {bar: true}, + "should write the correct value for bar"); + assert.deepEqual(args.kwargs.context, {default_bar: true}, + "should send the correct default value for bar"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 2, + "quick create should be enabled when grouped on a boolean field"); + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 3, + "second column (true) should contain 3 records"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:nth(1) .o_kanban_quick_add i')); + assert.ok(kanban.$('.o_kanban_quick_create .o_field_boolean input').is(':checked'), + "should have set the correct bar value by default"); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 4, + "second column (true) should now contain 4 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by selection field (within quick_create_view)', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="state"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="state"/>' + + '</form>', + }, + groupBy: ['state'], + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0], {state: 'abc'}, + "should write the correct value for state"); + assert.deepEqual(args.kwargs.context, {default_state: 'abc'}, + "should send the correct default value for state"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3, + "quick create should be enabled when grouped on a selection field"); + assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record', + "first column (abc) should contain 1 record"); + + await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i')); + assert.strictEqual(kanban.$('.o_kanban_quick_create select').val(), '"abc"', + "should have set the correct state value by default"); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2, + "first column (abc) should now contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record while adding a new column', async function (assert) { + assert.expect(10); + + var def = testUtils.makeTestPromise(); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create' && args.model === 'product') { + return def.then(_.constant(result)); + } + return result; + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2); + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2); + + // add a new column + assert.containsOnce(kanban, '.o_column_quick_create'); + assert.isNotVisible(kanban.$('.o_column_quick_create input')); + + await testUtils.dom.click(kanban.$('.o_quick_create_folded')); + + assert.isVisible(kanban.$('.o_column_quick_create input')); + + await testUtils.fields.editInput(kanban.$('.o_column_quick_create input'), 'new column'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group', 2); + + // click to add a new record + await testUtils.dom.click(kanban.$buttons.find('.o-kanban-button-new')); + + // should wait for the column to be created (and view to be re-rendered + // before opening the quick create + assert.containsNone(kanban, '.o_kanban_quick_create'); + + // unlock column creation + def.resolve(); + await testUtils.nextTick(); + assert.containsN(kanban, '.o_kanban_group', 3); + assert.containsOnce(kanban, '.o_kanban_quick_create'); + + // quick create record in first column + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record'); + await testUtils.dom.click(kanban.$('.o_kanban_quick_create .o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3); + + kanban.destroy(); + }); + + QUnit.test('close a column while quick creating a record', async function (assert) { + assert.expect(6); + + const def = testUtils.makeTestPromise(); + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban on_create="quick_create" quick_create_view="some_view_ref"> + <templates><t t-name="kanban-box"> + <div><field name="foo"/></div> + </t></templates> + </kanban>`, + archs: { + 'partner,some_view_ref,form': '<form><field name="int_field"/></form>', + }, + groupBy: ['product_id'], + async mockRPC(route, args) { + const result = this._super(...arguments); + if (args.method === 'load_views') { + await def; + } + return result; + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2); + assert.containsNone(kanban, '.o_column_folded'); + + // click to quick create a new record in the first column (this operation is delayed) + await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_quick_add')); + + assert.containsNone(kanban, '.o_form_view'); + + // click to fold the first column + await testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first')); + await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold')); + + assert.containsOnce(kanban, '.o_column_folded'); + + def.resolve(); + await testUtils.nextTick(); + + assert.containsNone(kanban, '.o_form_view'); + assert.containsOnce(kanban, '.o_column_folded'); + + kanban.destroy(); + }); + + QUnit.test('quick create record: open on a column while another column has already one', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + // Click on quick create in first column + await testUtils.dom.click(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_quick_add')); + assert.containsOnce(kanban, '.o_kanban_quick_create'); + assert.containsOnce(kanban.$('.o_kanban_group:nth-child(1)'), '.o_kanban_quick_create'); + + // Click on quick create in second column + await testUtils.dom.click(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_quick_add')); + assert.containsOnce(kanban, '.o_kanban_quick_create'); + assert.containsOnce(kanban.$('.o_kanban_group:nth-child(2)'), '.o_kanban_quick_create'); + + // Click on quick create in first column once again + await testUtils.dom.click(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_quick_add')); + assert.containsOnce(kanban, '.o_kanban_quick_create'); + assert.containsOnce(kanban.$('.o_kanban_group:nth-child(1)'), '.o_kanban_quick_create'); + + kanban.destroy(); + }); + + QUnit.test('many2many_tags in kanban views', async function (assert) { + assert.expect(12); + + this.data.partner.records[0].category_ids = [6, 7]; + this.data.partner.records[1].category_ids = [7, 8]; + this.data.category.records.push({ + id: 8, + name: "hello", + color: 0, + }); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates><t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<field name="category_ids" widget="many2many_tags" options="{\'color_field\': \'color\'}"/>' + + '<field name="foo"/>' + + '<field name="state" widget="priority"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + mockRPC: function (route) { + assert.step(route); + return this._super.apply(this, arguments); + }, + intercepts: { + switch_view: function (event) { + assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), { + mode: 'readonly', + model: 'partner', + res_id: 1, + view_type: 'form', + }, "should trigger an event to open the clicked record in a form view"); + }, + }, + }); + + var $first_record = kanban.$('.o_kanban_record:first()'); + assert.strictEqual($first_record.find('.o_field_many2manytags .o_tag').length, 2, + 'first record should contain 2 tags'); + assert.hasClass($first_record.find('.o_tag:first()'),'o_tag_color_2', + 'first tag should have color 2'); + assert.verifySteps(['/web/dataset/search_read', '/web/dataset/call_kw/category/read'], + 'two RPC should have been done (one search read and one read for the m2m)'); + + // Checks that second records has only one tag as one should be hidden (color 0) + assert.strictEqual(kanban.$('.o_kanban_record').eq(1).find('.o_tag').length, 1, + 'there should be only one tag in second record'); + + // Write on the record using the priority widget to trigger a re-render in readonly + await testUtils.dom.click(kanban.$('.o_field_widget.o_priority a.o_priority_star.fa-star-o').first()); + assert.verifySteps([ + '/web/dataset/call_kw/partner/write', + '/web/dataset/call_kw/partner/read', + '/web/dataset/call_kw/category/read' + ], 'five RPCs should have been done (previous 2, 1 write (triggers a re-render), same 2 at re-render'); + assert.strictEqual(kanban.$('.o_kanban_record:first()').find('.o_field_many2manytags .o_tag').length, 2, + 'first record should still contain only 2 tags'); + + // click on a tag (should trigger switch_view) + await testUtils.dom.click(kanban.$('.o_tag:contains(gold):first')); + + kanban.destroy(); + }); + + QUnit.test('Do not open record when clicking on `a` with `href`', async function (assert) { + assert.expect(5); + + this.data.partner.records = [ + { id: 1, foo: 'yop' }, + ]; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<field name="foo"/>' + + '<div>' + + '<a class="o_test_link" href="#">test link</a>' + + '</div>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + intercepts: { + // when clicking on a record in kanban view, + // it switches to form view. + switch_view: function () { + throw new Error("should not switch view"); + }, + }, + doNotDisableAHref: true, + }); + + var $record = kanban.$('.o_kanban_record:not(.o_kanban_ghost)'); + assert.strictEqual($record.length, 1, + "should display a kanban record"); + + var $testLink = $record.find('a'); + assert.strictEqual($testLink.length, 1, + "should contain a link in the kanban record"); + assert.ok(!!$testLink[0].href, + "link inside kanban record should have non-empty href"); + + // Prevent the browser default behaviour when clicking on anything. + // This includes clicking on a `<a>` with `href`, so that it does not + // change the URL in the address bar. + // Note that we should not specify a click listener on 'a', otherwise + // it may influence the kanban record global click handler to not open + // the record. + $(document.body).on('click.o_test', function (ev) { + assert.notOk(ev.isDefaultPrevented(), + "should not prevented browser default behaviour beforehand"); + assert.strictEqual(ev.target, $testLink[0], + "should have clicked on the test link in the kanban record"); + ev.preventDefault(); + }); + + await testUtils.dom.click($testLink); + + $(document.body).off('click.o_test'); + kanban.destroy(); + }); + + QUnit.test('o2m loaded in only one batch', async function (assert) { + assert.expect(9); + + this.data.subtask = { + fields: { + name: {string: 'Name', type: 'char'} + }, + records: [ + {id: 1, name: "subtask #1"}, + {id: 2, name: "subtask #2"}, + ] + }; + this.data.partner.fields.subtask_ids = { + string: 'Subtasks', + type: 'one2many', + relation: 'subtask' + }; + this.data.partner.records[0].subtask_ids = [1]; + this.data.partner.records[1].subtask_ids = [2]; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="subtask_ids" widget="many2many_tags"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + await kanban.reload(); + assert.verifySteps([ + 'web_read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + 'web_read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + ]); + kanban.destroy(); + }); + + QUnit.test('m2m loaded in only one batch', async function (assert) { + assert.expect(9); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="category_ids" widget="many2many_tags"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + await kanban.reload(kanban); + assert.verifySteps([ + 'web_read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + 'web_read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + ]); + kanban.destroy(); + }); + + QUnit.test('fetch reference in only one batch', async function (assert) { + assert.expect(9); + + this.data.partner.records[0].ref_product = 'product,3'; + this.data.partner.records[1].ref_product = 'product,5'; + this.data.partner.fields.ref_product = { + string: "Reference Field", + type: 'reference', + }; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<field name="ref_product"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + await kanban.reload(); + assert.verifySteps([ + 'web_read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'name_get', + 'web_read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'name_get', + ]); + kanban.destroy(); + }); + + QUnit.test('wait x2manys batch fetches to re-render', async function (assert) { + assert.expect(7); + var done = assert.async(); + + var def = Promise.resolve(); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="category_ids" widget="many2many_tags"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + var result = this._super(route, args); + if (args.method === 'read') { + return def.then(function() { + return result; + }); + } + return result; + }, + }); + + def = testUtils.makeTestPromise(); + assert.containsN(kanban, '.o_tag', 2); + assert.containsN(kanban, '.o_kanban_group', 2); + kanban.update({groupBy: ['state']}); + def.then(async function () { + assert.containsN(kanban, '.o_kanban_group', 2); + await testUtils.nextTick(); + assert.containsN(kanban, '.o_kanban_group', 3); + + assert.containsN(kanban, '.o_tag', 2, + 'Should display 2 tags after update'); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_tag').text(), + 'gold', 'First category should be \'gold\''); + assert.strictEqual(kanban.$('.o_kanban_group:eq(2) .o_tag').text(), + 'silver', 'Second category should be \'silver\''); + kanban.destroy(); + done(); + }); + await testUtils.nextTick(); + def.resolve(); + }); + + QUnit.test('can drag and drop a record from one column to the next', async function (assert) { + assert.expect(9); + + // @todo: remove this resequenceDef whenever the jquery upgrade branch + // is merged. This is currently necessary to simulate the reality: we + // need the click handlers to be executed after the end of the drag and + // drop operation, not before. + var resequenceDef = testUtils.makeTestPromise(); + + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test + this.data.partner.fields.sequence = {type: 'number', string: "Sequence"}; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div class="oe_kanban_global_click"><field name="foo"/>' + + '<t t-if="widget.editable"><span class="thisiseditable">edit</span></t>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + assert.ok(true, "should call resequence"); + return resequenceDef.then(_.constant(true)); + } + return this._super(route, args); + }, + }); + assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2); + assert.containsN(kanban, '.thisiseditable', 4); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + var $group = kanban.$('.o_kanban_group:nth-child(2)'); + envIDs = [3, 2, 4, 1]; // first record of first column moved to the bottom of second column + await testUtils.dom.dragAndDrop($record, $group, {withTrailingClick: true}); + + resequenceDef.resolve(); + await testUtils.nextTick(); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3); + assert.containsN(kanban, '.thisiseditable', 4); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + resequenceDef.resolve(); + kanban.destroy(); + }); + + QUnit.test('drag and drop a record, grouped by selection', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div><field name="state"/></div>' + + '</t>' + + '</templates>' + + '</kanban>', + groupBy: ['state'], + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + assert.ok(true, "should call resequence"); + return Promise.resolve(true); + } + if (args.model === 'partner' && args.method === 'write') { + assert.deepEqual(args.args[1], {state: 'def'}); + } + return this._super(route, args); + }, + }); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record'); + + var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + var $group = kanban.$('.o_kanban_group:nth-child(2)'); + await testUtils.dom.dragAndDrop($record, $group); + await nextTick(); // wait for resequence after drag and drop + + assert.containsNone(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2); + kanban.destroy(); + }); + + QUnit.test('prevent drag and drop of record if grouped by readonly', async function (assert) { + assert.expect(12); + + this.data.partner.fields.foo.readonly = true; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates>' + + '<t t-name="kanban-box"><div>' + + '<field name="foo"/>' + + '<field name="state" readonly="1"/>' + + '</div></t>' + + '</templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + return Promise.resolve(); + } + if (args.model === 'partner' && args.method === 'write') { + throw new Error('should not be draggable'); + } + return this._super(route, args); + }, + }); + // simulate an update coming from the searchview, with another groupby given + await kanban.update({groupBy: ['state']}); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record'); + + // drag&drop a record in another column + var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + var $group = kanban.$('.o_kanban_group:nth-child(2)'); + await testUtils.dom.dragAndDrop($record, $group); + await nextTick(); // wait for resequence after drag and drop + // should not be draggable + assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record'); + + // simulate an update coming from the searchview, with another groupby given + await kanban.update({groupBy: ['foo']}); + assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2); + + // drag&drop a record in another column + $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + $group = kanban.$('.o_kanban_group:nth-child(2)'); + await testUtils.dom.dragAndDrop($record, $group); + await nextTick(); // wait for resequence after drag and drop + // should not be draggable + assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2); + + // drag&drop a record in the same column + var $record1 = kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(0)'); + var $record2 = kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(1)'); + assert.strictEqual($record1.text(), "blipDEF", "first record should be DEF"); + assert.strictEqual($record2.text(), "blipGHI", "second record should be GHI"); + await testUtils.dom.dragAndDrop($record2, $record1, {position: 'top'}); + // should still be able to resequence + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(0)').text(), "blipGHI", + "records should have been resequenced"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(1)').text(), "blipDEF", + "records should have been resequenced"); + + kanban.destroy(); + }); + + QUnit.test('prevent drag and drop if grouped by date/datetime field', async function (assert) { + assert.expect(5); + + this.data.partner.records[0].date = '2017-01-08'; + this.data.partner.records[1].date = '2017-01-09'; + this.data.partner.records[2].date = '2017-02-08'; + this.data.partner.records[3].date = '2017-02-10'; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['date:month'], + }); + + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have 2 columns"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2, + "1st column should contain 2 records of January month"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length , 2, + "2nd column should contain 2 records of February month"); + + // drag&drop a record in another column + var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + var $group = kanban.$('.o_kanban_group:nth-child(2)'); + await testUtils.dragAndDrop($record, $group); + + // should not drag&drop record + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length , 2, + "Should remain same records in first column(2 records)"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length , 2, + "Should remain same records in 2nd column(2 record)"); + kanban.destroy(); + }); + + QUnit.test('completely prevent drag and drop if records_draggable set to false', async function (assert) { + assert.expect(6); + + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" records_draggable="false">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['product_id'], + }); + + // testing initial state + assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + // attempt to drag&drop a record in another column + var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + var $group = kanban.$('.o_kanban_group:nth-child(2)'); + await testUtils.dom.dragAndDrop($record, $group, {withTrailingClick: true}); + + // should not drag&drop record + assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2, + "First column should still contain 2 records"); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2, + "Second column should still contain 2 records"); + assert.deepEqual(kanban.exportState().resIds, envIDs, "Records should not have moved"); + + kanban.destroy(); + }); + + QUnit.test('prevent drag and drop of record if onchange fails', async function (assert) { + assert.expect(4); + + this.data.partner.onchanges = { + product_id: function (obj) {} + }; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates>' + + '<t t-name="kanban-box"><div>' + + '<field name="foo"/>' + + '<field name="product_id"/>' + + '</div></t>' + + '</templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/onchange') { + return Promise.reject({}); + } + return this._super(route, args); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2, + "column should contain 2 records"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 2, + "column should contain 2 records"); + // drag&drop a record in another column + var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + var $group = kanban.$('.o_kanban_group:nth-child(2)'); + await testUtils.dom.dragAndDrop($record, $group); + // should not be dropped, card should reset back to first column + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2, + "column should now contain 2 records"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 2, + "column should contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('kanban view with default_group_by', async function (assert) { + assert.expect(7); + this.data.partner.records.product_id = 1; + this.data.product.records.push({id: 1, display_name: "third product"}); + + var readGroupCount = 0; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" default_group_by="bar">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/web_read_group') { + readGroupCount++; + var correctGroupBy; + if (readGroupCount === 2) { + correctGroupBy = ['product_id']; + } else { + correctGroupBy = ['bar']; + } + // this is done three times + assert.ok(_.isEqual(args.kwargs.groupby, correctGroupBy), + "groupby args should be correct"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); + assert.containsN(kanban, '.o_kanban_group', 2, "should have " + 2 + " columns"); + + // simulate an update coming from the searchview, with another groupby given + await kanban.update({groupBy: ['product_id']}); + assert.containsN(kanban, '.o_kanban_group', 2, "should now have " + 3 + " columns"); + + // simulate an update coming from the searchview, removing the previously set groupby + await kanban.update({groupBy: []}); + assert.containsN(kanban, '.o_kanban_group', 2, "should have " + 2 + " columns again"); + kanban.destroy(); + }); + + QUnit.test('kanban view not groupable', async function (assert) { + assert.expect(3); + + const searchMenuTypesOriginal = KanbanView.prototype.searchMenuTypes; + KanbanView.prototype.searchMenuTypes = ['filter', 'favorite']; + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban class="o_kanban_test" default_group_by="bar"> + <field name="bar"/> + <templates> + <t t-name="kanban-box"> + <div><field name="foo"/></div> + </t> + </templates> + </kanban> + `, + archs: { + 'partner,false,search': ` + <search> + <filter string="candle" name="itsName" context="{'group_by': '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_itsName: 1, }, + }); + + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); + assert.containsNone(kanban, '.o_control_panel div.o_search_options div.o_group_by_menu'); + assert.deepEqual(cpHelpers.getFacetTexts(kanban), []); + + kanban.destroy(); + KanbanView.prototype.searchMenuTypes = searchMenuTypesOriginal; + }); + + QUnit.test('kanban view with create=False', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" create="0">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + }); + + assert.ok(!kanban.$buttons || !kanban.$buttons.find('.o-kanban-button-new').length, + "Create button shouldn't be there"); + kanban.destroy(); + }); + + QUnit.test('clicking on a link triggers correct event', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><a type="edit">Edit</a></div>' + + '</t></templates></kanban>', + }); + + testUtils.mock.intercept(kanban, 'switch_view', function (event) { + assert.deepEqual(event.data, { + view_type: 'form', + res_id: 1, + mode: 'edit', + model: 'partner', + }); + }); + await testUtils.dom.click(kanban.$('a').first()); + kanban.destroy(); + }); + + QUnit.test('environment is updated when (un)folding groups', async function (assert) { + assert.expect(3); + + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.deepEqual(kanban.exportState().resIds, envIDs); + + // fold the second group and check that the res_ids it contains are no + // longer in the environment + envIDs = [1, 3]; + await testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_kanban_toggle_fold')); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + // re-open the second group and check that the res_ids it contains are + // back in the environment + envIDs = [1, 3, 2, 4]; + await testUtils.dom.click(kanban.$('.o_kanban_group:last')); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + kanban.destroy(); + }); + + QUnit.test('create a column in grouped on m2o', async function (assert) { + assert.expect(14); + + var nbRPCs = 0; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + nbRPCs++; + if (args.method === 'name_create') { + assert.ok(true, "should call name_create"); + } + //Create column will call resequence to set column order + if (route === '/web/dataset/resequence') { + assert.ok(true, "should call resequence"); + return Promise.resolve(true); + } + return this._super(route, args); + }, + }); + assert.containsOnce(kanban, '.o_column_quick_create', "should have a quick create column"); + assert.notOk(kanban.$('.o_column_quick_create input').is(':visible'), + "the input should not be visible"); + + await testUtils.dom.click(kanban.$('.o_quick_create_folded')); + + assert.ok(kanban.$('.o_column_quick_create input').is(':visible'), + "the input should be visible"); + + // discard the column creation and click it again + await kanban.$('.o_column_quick_create input').trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ESCAPE, + which: $.ui.keyCode.ESCAPE, + })); + assert.notOk(kanban.$('.o_column_quick_create input').is(':visible'), + "the input should not be visible after discard"); + + await testUtils.dom.click(kanban.$('.o_quick_create_folded')); + assert.ok(kanban.$('.o_column_quick_create input').is(':visible'), + "the input should be visible"); + + await kanban.$('.o_column_quick_create input').val('new value').trigger('input'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + + assert.strictEqual(kanban.$('.o_kanban_group:last span:contains(new value)').length, 1, + "the last column should be the newly created one"); + assert.ok(_.isNumber(kanban.$('.o_kanban_group:last').data('id')), + 'the created column should have the correct id'); + assert.doesNotHaveClass(kanban.$('.o_kanban_group:last'), 'o_column_folded', + 'the created column should not be folded'); + + // fold and unfold the created column, and check that no RPC is done (as there is no record) + nbRPCs = 0; + await testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_kanban_toggle_fold')); + assert.hasClass(kanban.$('.o_kanban_group:last'),'o_column_folded', + 'the created column should now be folded'); + await testUtils.dom.click(kanban.$('.o_kanban_group:last')); + assert.doesNotHaveClass(kanban.$('.o_kanban_group:last'), 'o_column_folded'); + assert.strictEqual(nbRPCs, 0, 'no rpc should have been done when folding/unfolding'); + + // quick create a record + await testUtils.kanban.clickCreate(kanban); + assert.hasClass(kanban.$('.o_kanban_group:first() > div:nth(1)'),'o_kanban_quick_create', + "clicking on create should open the quick_create in the first column"); + kanban.destroy(); + }); + + QUnit.test('auto fold group when reach the limit', async function (assert) { + assert.expect(9); + + var data = this.data; + for (var i = 0; i < 12; i++) { + data.product.records.push({ + id: (8 + i), + name: ("column"), + }); + data.partner.records.push({ + id: (20 + i), + foo: ("dumb entry"), + product_id: (8 + i), + }); + } + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: data, + arch: '<kanban class="o_kanban_test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + return this._super.apply(this, arguments).then(function (result) { + result.groups[2].__fold = true; + result.groups[8].__fold = true; + return result; + }); + } + return this._super(route, args); + }, + }); + + // we look if column are fold/unfold according what is expected + assert.doesNotHaveClass(kanban.$('.o_kanban_group:nth-child(2)'), 'o_column_folded'); + assert.doesNotHaveClass(kanban.$('.o_kanban_group:nth-child(4)'), 'o_column_folded'); + assert.doesNotHaveClass(kanban.$('.o_kanban_group:nth-child(10)'), 'o_column_folded'); + assert.hasClass(kanban.$('.o_kanban_group:nth-child(3)'), 'o_column_folded'); + assert.hasClass(kanban.$('.o_kanban_group:nth-child(9)'), 'o_column_folded'); + + // we look if columns are actually fold after we reached the limit + assert.hasClass(kanban.$('.o_kanban_group:nth-child(13)'), 'o_column_folded'); + assert.hasClass(kanban.$('.o_kanban_group:nth-child(14)'), 'o_column_folded'); + + // we look if we have the right count of folded/unfolded column + assert.containsN(kanban, '.o_kanban_group:not(.o_column_folded)', 10); + assert.containsN(kanban, '.o_kanban_group.o_column_folded', 4); + + kanban.destroy(); + }); + + QUnit.test('hide and display help message (ESC) in kanban quick create', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + await testUtils.dom.click(kanban.$('.o_quick_create_folded')); + assert.ok(kanban.$('.o_discard_msg').is(':visible'), + 'the ESC to discard message is visible'); + + // click outside the column (to lose focus) + await testUtils.dom.clickFirst(kanban.$('.o_kanban_header')); + assert.notOk(kanban.$('.o_discard_msg').is(':visible'), + 'the ESC to discard message is no longer visible'); + + kanban.destroy(); + }); + + QUnit.test('delete a column in grouped on m2o', async function (assert) { + assert.expect(37); + + testUtils.mock.patch(KanbanRenderer, { + _renderGrouped: function () { + this._super.apply(this, arguments); + // set delay and revert animation time to 0 so dummy drag and drop works + if (this.$el.sortable('instance')) { + this.$el.sortable('option', {delay: 0, revert: 0}); + } + }, + }); + + var resequencedIDs; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + resequencedIDs = args.ids; + assert.strictEqual(_.reject(args.ids, _.isNumber).length, 0, + "column resequenced should be existing records with IDs"); + return Promise.resolve(true); + } + if (args.method) { + assert.step(args.method); + } + return this._super(route, args); + }, + }); + + // check the initial rendering + assert.containsN(kanban, '.o_kanban_group', 2, "should have two columns"); + assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 3, + 'first column should be [3, "hello"]'); + assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 5, + 'second column should be [5, "xmo"]'); + assert.strictEqual(kanban.$('.o_kanban_group:last .o_column_title').text(), 'xmo', + 'second column should have correct title'); + assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 2, + "second column should have two records"); + + // check available actions in kanban header's config dropdown + assert.ok(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').length, + "should be able to fold the column"); + assert.ok(kanban.$('.o_kanban_group:first .o_column_edit').length, + "should be able to edit the column"); + assert.ok(kanban.$('.o_kanban_group:first .o_column_delete').length, + "should be able to delete the column"); + assert.ok(!kanban.$('.o_kanban_group:first .o_column_archive_records').length, "should not be able to archive all the records"); + assert.ok(!kanban.$('.o_kanban_group:first .o_column_unarchive_records').length, "should not be able to restore all the records"); + + // delete second column (first cancel the confirm request, then confirm) + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_delete')); + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + await testUtils.modal.clickButton('Cancel'); // click on cancel + assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 5, + 'column [5, "xmo"] should still be there'); + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_delete')); + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + await testUtils.modal.clickButton('Ok'); // click on confirm + assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 3, + 'last column should now be [3, "hello"]'); + assert.containsN(kanban, '.o_kanban_group', 2, "should still have two columns"); + assert.ok(!_.isNumber(kanban.$('.o_kanban_group:first').data('id')), + 'first column should have no id (Undefined column)'); + // check available actions on 'Undefined' column + assert.ok(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').length, + "should be able to fold the column"); + assert.ok(!kanban.$('.o_kanban_group:first .o_column_delete').length, + 'Undefined column could not be deleted'); + assert.ok(!kanban.$('.o_kanban_group:first .o_column_edit').length, + 'Undefined column could not be edited'); + assert.ok(!kanban.$('.o_kanban_group:first .o_column_archive_records').length, "Records of undefined column could not be archived"); + assert.ok(!kanban.$('.o_kanban_group:first .o_column_unarchive_records').length, "Records of undefined column could not be restored"); + assert.verifySteps(['web_read_group', 'unlink', 'web_read_group']); + assert.strictEqual(kanban.renderer.widgets.length, 2, + "the old widgets should have been correctly deleted"); + + // test column drag and drop having an 'Undefined' column + await testUtils.dom.dragAndDrop( + kanban.$('.o_column_title:first'), + kanban.$('.o_column_title:last'), {position: 'right'} + ); + assert.strictEqual(resequencedIDs, undefined, + "resequencing require at least 2 not Undefined columns"); + await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded')); + kanban.$('.o_column_quick_create input').val('once third column'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + var newColumnID = kanban.$('.o_kanban_group:last').data('id'); + await testUtils.dom.dragAndDrop( + kanban.$('.o_column_title:first'), + kanban.$('.o_column_title:last'), {position: 'right'} + ); + assert.deepEqual([3, newColumnID], resequencedIDs, + "moving the Undefined column should not affect order of other columns"); + await testUtils.dom.dragAndDrop( + kanban.$('.o_column_title:first'), + kanban.$('.o_column_title:nth(1)'), {position: 'right'} + ); + await nextTick(); // wait for resequence after drag and drop + assert.deepEqual([newColumnID, 3], resequencedIDs, + "moved column should be resequenced accordingly"); + assert.verifySteps(['name_create', 'read', 'read', 'read']); + + kanban.destroy(); + testUtils.mock.unpatch(KanbanRenderer); + }); + + QUnit.test('create a column, delete it and create another one', async function (assert) { + assert.expect(5); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.containsN(kanban, '.o_kanban_group', 2, "should have two columns"); + + await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded')); + kanban.$('.o_column_quick_create input').val('new column 1'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group', 3, "should have two columns"); + + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); + await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_delete')); + await testUtils.modal.clickButton('Ok'); + + assert.containsN(kanban, '.o_kanban_group', 2, "should have twos columns"); + + await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded')); + kanban.$('.o_column_quick_create input').val('new column 2'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + + assert.containsN(kanban, '.o_kanban_group', 3, "should have three columns"); + assert.strictEqual(kanban.$('.o_kanban_group:last span:contains(new column 2)').length, 1, + "the last column should be the newly created one"); + kanban.destroy(); + }); + + QUnit.test('edit a column in grouped on m2o', async function (assert) { + assert.expect(12); + + var nbRPCs = 0; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + archs: { + 'product,false,form': '<form string="Product"><field name="display_name"/></form>', + }, + mockRPC: function (route, args) { + nbRPCs++; + return this._super(route, args); + }, + }); + assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'xmo', + 'title of the column should be "xmo"'); + + // edit the title of column [5, 'xmo'] and close without saving + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]')); + await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit')); + assert.containsOnce(document.body, '.modal .o_form_editable', + "a form view should be open in a modal"); + assert.strictEqual($('.modal .o_form_editable input').val(), 'xmo', + 'the name should be "xmo"'); + await testUtils.fields.editInput($('.modal .o_form_editable input'), 'ged'); // change the value + nbRPCs = 0; + await testUtils.dom.click($('.modal-header .close')); + assert.containsNone(document.body, '.modal'); + assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'xmo', + 'title of the column should still be "xmo"'); + assert.strictEqual(nbRPCs, 0, 'no RPC should have been done'); + + // edit the title of column [5, 'xmo'] and discard + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]')); + await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit')); + await testUtils.fields.editInput($('.modal .o_form_editable input'), 'ged'); // change the value + nbRPCs = 0; + await testUtils.modal.clickButton('Discard'); + assert.containsNone(document.body, '.modal'); + assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'xmo', + 'title of the column should still be "xmo"'); + assert.strictEqual(nbRPCs, 0, 'no RPC should have been done'); + + // edit the title of column [5, 'xmo'] and save + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]')); + await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit')); + await testUtils.fields.editInput($('.modal .o_form_editable input'), 'ged'); // change the value + nbRPCs = 0; + await testUtils.modal.clickButton('Save'); // click on save + assert.ok(!$('.modal').length, 'the modal should be closed'); + assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'ged', + 'title of the column should be "ged"'); + assert.strictEqual(nbRPCs, 4, 'should have done 1 write, 1 read_group and 2 search_read'); + kanban.destroy(); + }); + + QUnit.test('edit a column propagates right context', async function (assert) { + assert.expect(4); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + archs: { + 'product,false,form': '<form string="Product"><field name="display_name"/></form>', + }, + session: {user_context: {lang: 'brol'}}, + mockRPC: function (route, args) { + let context; + if (route === '/web/dataset/search_read' && args.model === 'partner') { + context = args.context; + assert.strictEqual(context.lang, 'brol', + 'lang is present in context for partner operations'); + } + if (args.model === 'product') { + context = args.kwargs.context; + assert.strictEqual(context.lang, 'brol', + 'lang is present in context for product operations'); + } + return this._super.apply(this, arguments); + }, + }); + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]')); + await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit')); + kanban.destroy(); + }); + + QUnit.test('quick create column should be opened if there is no column', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + domain: [['foo', '=', 'norecord']], + }); + + assert.containsNone(kanban, '.o_kanban_group'); + assert.containsOnce(kanban, '.o_column_quick_create'); + assert.ok(kanban.$('.o_column_quick_create input').is(':visible'), + "the quick create should be opened"); + + kanban.destroy(); + }); + + QUnit.test('quick create several columns in a row', async function (assert) { + assert.expect(10); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.containsN(kanban, '.o_kanban_group', 2, + "should have two columns"); + assert.containsOnce(kanban, '.o_column_quick_create', + "should have a ColumnQuickCreate widget"); + assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_folded:visible', + "the ColumnQuickCreate should be folded"); + assert.containsNone(kanban, '.o_column_quick_create .o_quick_create_unfolded:visible', + "the ColumnQuickCreate should be folded"); + + // add a new column + await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded')); + assert.containsNone(kanban, '.o_column_quick_create .o_quick_create_folded:visible', + "the ColumnQuickCreate should be unfolded"); + assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded:visible', + "the ColumnQuickCreate should be unfolded"); + kanban.$('.o_column_quick_create input').val('New Column 1'); + await testUtils.dom.click(kanban.$('.o_column_quick_create .btn-primary')); + assert.containsN(kanban, '.o_kanban_group', 3, + "should now have three columns"); + + // add another column + assert.containsNone(kanban, '.o_column_quick_create .o_quick_create_folded:visible', + "the ColumnQuickCreate should still be unfolded"); + assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded:visible', + "the ColumnQuickCreate should still be unfolded"); + kanban.$('.o_column_quick_create input').val('New Column 2'); + await testUtils.dom.click(kanban.$('.o_column_quick_create .btn-primary')); + assert.containsN(kanban, '.o_kanban_group', 4, + "should now have four columns"); + + kanban.destroy(); + }); + + QUnit.test('quick create column and examples', async function (assert) { + assert.expect(12); + + kanbanExamplesRegistry.add('test', { + examples:[{ + name: "A first example", + columns: ["Column 1", "Column 2", "Column 3"], + description: "Some description", + }, { + name: "A second example", + columns: ["Col 1", "Col 2"], + }], + }); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban examples="test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.containsOnce(kanban, '.o_column_quick_create', + "should have a ColumnQuickCreate widget"); + + // open the quick create + await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded')); + + assert.containsOnce(kanban, '.o_column_quick_create .o_kanban_examples:visible', + "should have a link to see examples"); + + // click to see the examples + await testUtils.dom.click(kanban.$('.o_column_quick_create .o_kanban_examples')); + + assert.strictEqual($('.modal .o_kanban_examples_dialog').length, 1, + "should have open the examples dialog"); + assert.strictEqual($('.modal .o_kanban_examples_dialog_nav li').length, 2, + "should have two examples (in the menu)"); + assert.strictEqual($('.modal .o_kanban_examples_dialog_nav a').text(), + ' A first example A second example ', "example names should be correct"); + assert.strictEqual($('.modal .o_kanban_examples_dialog_content .tab-pane').length, 2, + "should have two examples"); + + var $firstPane = $('.modal .o_kanban_examples_dialog_content .tab-pane:first'); + assert.strictEqual($firstPane.find('.o_kanban_examples_group').length, 3, + "there should be 3 stages in the first example"); + assert.strictEqual($firstPane.find('h6').text(), 'Column 1Column 2Column 3', + "column titles should be correct"); + assert.strictEqual($firstPane.find('.o_kanban_examples_description').text().trim(), + "Some description", "the correct description should be displayed"); + + var $secondPane = $('.modal .o_kanban_examples_dialog_content .tab-pane:nth(1)'); + assert.strictEqual($secondPane.find('.o_kanban_examples_group').length, 2, + "there should be 2 stages in the second example"); + assert.strictEqual($secondPane.find('h6').text(), 'Col 1Col 2', + "column titles should be correct"); + assert.strictEqual($secondPane.find('.o_kanban_examples_description').text().trim(), + "", "there should be no description for the second example"); + + kanban.destroy(); + }); + + QUnit.test('quick create column and examples background with ghostColumns titles', async function (assert) { + assert.expect(4); + + this.data.partner.records = []; + + kanbanExamplesRegistry.add('test', { + ghostColumns: ["Ghost 1", "Ghost 2", "Ghost 3", "Ghost 4"], + examples:[{ + name: "A first example", + columns: ["Column 1", "Column 2", "Column 3"], + description: "Some description", + }, { + name: "A second example", + columns: ["Col 1", "Col 2"], + }], + }); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban examples="test">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.containsOnce(kanban, '.o_kanban_example_background', + "should have ExamplesBackground when no data"); + assert.strictEqual(kanban.$('.o_kanban_examples_group h6').text(), 'Ghost 1Ghost 2Ghost 3Ghost 4', + "ghost title should be correct"); + assert.containsOnce(kanban, '.o_column_quick_create', + "should have a ColumnQuickCreate widget"); + assert.containsOnce(kanban, '.o_column_quick_create .o_kanban_examples:visible', + "should not have a link to see examples as there is no examples registered"); + + kanban.destroy(); + }); + + QUnit.test('quick create column and examples background without ghostColumns titles', async function (assert) { + assert.expect(4); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.containsOnce(kanban, '.o_kanban_example_background', + "should have ExamplesBackground when no data"); + assert.strictEqual(kanban.$('.o_kanban_examples_group h6').text(), 'Column 1Column 2Column 3Column 4', + "ghost title should be correct"); + assert.containsOnce(kanban, '.o_column_quick_create', + "should have a ColumnQuickCreate widget"); + assert.containsNone(kanban, '.o_column_quick_create .o_kanban_examples:visible', + "should not have a link to see examples as there is no examples registered"); + + kanban.destroy(); + }); + + QUnit.test('nocontent helper after adding a record (kanban with progressbar)', async function (assert) { + assert.expect(3); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: `<kanban > + <field name="product_id"/> + <progressbar field="foo" colors='{"yop": "success", "gnap": "warning", "blip": "danger"}' sum_field="int_field"/> + <templates> + <t t-name="kanban-box"> + <div><field name="foo"/></div> + </t> + </templates> + </kanban>`, + groupBy: ['product_id'], + domain: [['foo', '=', 'abcd']], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + const result = { + groups: [ + { __domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello'] }, + ], + }; + return Promise.resolve(result); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsOnce(kanban, '.o_view_nocontent', "the nocontent helper is displayed"); + + // add a record + await testUtils.dom.click(kanban.$('.o_kanban_quick_add')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle'); + await testUtils.dom.click(kanban.$('button.o_kanban_add')); + + assert.containsNone(kanban, '.o_view_nocontent', + "the nocontent helper is not displayed after quick create"); + + // cancel quick create + await testUtils.dom.click(kanban.$('button.o_kanban_cancel')); + assert.containsNone(kanban, '.o_view_nocontent', + "the nocontent helper is not displayed after cancelling the quick create"); + + kanban.destroy(); + }); + + QUnit.test('if view was not grouped at start, it can be grouped and ungrouped', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + }); + + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); + await kanban.update({groupBy: ['product_id']}); + assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped'); + await kanban.update({groupBy: []}); + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); + + kanban.destroy(); + }); + + QUnit.test('no content helper when archive all records in kanban group', async function (assert) { + assert.expect(3); + + // add active field on partner model to have archive option + this.data.partner.fields.active = {string: 'Active', type: 'boolean', default: true}; + // remove last records to have only one column + this.data.partner.records = this.data.partner.records.slice(0, 3); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: `<kanban class="o_kanban_test"> + <field name="active"/> + <field name="bar"/> + <templates> + <t t-name="kanban-box"> + <div><field name="foo"/></div> + </t> + </templates> + </kanban>`, + viewOptions: { + action: { + help: '<p class="hello">click to add a partner</p>' + } + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/action_archive') { + const partnerIDS = args.args[0]; + const records = this.data.partner.records; + _.each(partnerIDS, function (partnerID) { + _.find(records, function (record) { + return record.id === partnerID; + }).active = false; + }); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + // check that the (unique) column contains 3 records + assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3); + + // archive the records of the last column + testUtils.kanban.toggleGroupSettings($(kanban.el.querySelector('.o_kanban_group'))); // we should change the helper + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group .o_column_archive_records')); + assert.containsOnce(document.body, '.modal'); + await testUtils.modal.clickButton('Ok'); + // check no content helper is exist + assert.containsOnce(kanban, '.o_view_nocontent'); + kanban.destroy(); + }); + + QUnit.test('no content helper when no data', async function (assert) { + assert.expect(3); + + var records = this.data.partner.records; + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div>' + + '<t t-esc="record.foo.value"/>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates></kanban>', + viewOptions: { + action: { + help: '<p class="hello">click to add a partner</p>' + } + }, + }); + + assert.containsOnce(kanban, '.o_view_nocontent', + "should display the no content helper"); + + assert.strictEqual(kanban.$('.o_view_nocontent p.hello:contains(add a partner)').length, 1, + "should have rendered no content helper from action"); + + this.data.partner.records = records; + await kanban.reload(); + + assert.containsNone(kanban, '.o_view_nocontent', + "should not display the no content helper"); + kanban.destroy(); + }); + + QUnit.test('no nocontent helper for grouped kanban with empty groups', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + return this._super.apply(this, arguments).then(function (result) { + _.each(result.groups, function (group) { + group[args.kwargs.groupby[0] + '_count'] = 0; + }); + return result; + }); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, + "there should be two columns"); + assert.containsNone(kanban, '.o_kanban_record', + "there should be no records"); + + kanban.destroy(); + }); + + QUnit.test('no nocontent helper for grouped kanban with no records', async function (assert) { + assert.expect(4); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsNone(kanban, '.o_kanban_group', + "there should be no columns"); + assert.containsNone(kanban, '.o_kanban_record', + "there should be no records"); + assert.containsNone(kanban, '.o_view_nocontent', + "there should be no nocontent helper (we are in 'column creation mode')"); + assert.containsOnce(kanban, '.o_column_quick_create', + "there should be a column quick create"); + kanban.destroy(); + }); + + QUnit.test('no nocontent helper is shown when no longer creating column', async function (assert) { + assert.expect(3); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsNone(kanban, '.o_view_nocontent', + "there should be no nocontent helper (we are in 'column creation mode')"); + + // creating a new column + kanban.$('.o_column_quick_create .o_input').val('applejack'); + await testUtils.dom.click(kanban.$('.o_column_quick_create .o_kanban_add')); + + assert.containsNone(kanban, '.o_view_nocontent', + "there should be no nocontent helper (still in 'column creation mode')"); + + // leaving column creation mode + kanban.$('.o_column_quick_create .o_input').trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ESCAPE, + which: $.ui.keyCode.ESCAPE, + })); + + assert.containsOnce(kanban, '.o_view_nocontent', + "there should be a nocontent helper"); + + kanban.destroy(); + }); + + QUnit.test('no nocontent helper is hidden when quick creating a column', async function (assert) { + assert.expect(2); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + var result = { + groups: [ + {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']}, + ], + length: 1, + }; + return Promise.resolve(result); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsOnce(kanban, '.o_view_nocontent', + "there should be a nocontent helper"); + + await testUtils.dom.click(kanban.$('.o_kanban_add_column')); + + assert.containsNone(kanban, '.o_view_nocontent', + "there should be no nocontent helper (we are in 'column creation mode')"); + + kanban.destroy(); + }); + + QUnit.test('remove nocontent helper after adding a record', async function (assert) { + assert.expect(2); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + var result = { + groups: [ + {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']}, + ], + length: 1, + }; + return Promise.resolve(result); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsOnce(kanban, '.o_view_nocontent', + "there should be a nocontent helper"); + + // add a record + await testUtils.dom.click(kanban.$('.o_kanban_quick_add')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle'); + await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add')); + + assert.containsNone(kanban, '.o_view_nocontent', + "there should be no nocontent helper (there is now one record)"); + + kanban.destroy(); + }); + + QUnit.test('remove nocontent helper when adding a record', async function (assert) { + assert.expect(2); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + var result = { + groups: [ + {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']}, + ], + length: 1, + }; + return Promise.resolve(result); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsOnce(kanban, '.o_view_nocontent', + "there should be a nocontent helper"); + + // add a record + await testUtils.dom.click(kanban.$('.o_kanban_quick_add')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle'); + + assert.containsNone(kanban, '.o_view_nocontent', + "there should be no nocontent helper (there is now one record)"); + + kanban.destroy(); + }); + + QUnit.test('nocontent helper is displayed again after canceling quick create', async function (assert) { + assert.expect(1); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + var result = { + groups: [ + {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']}, + ], + length: 1, + }; + return Promise.resolve(result); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + // add a record + await testUtils.dom.click(kanban.$('.o_kanban_quick_add')); + + await testUtils.dom.click(kanban.$('.o_kanban_view')); + + assert.containsOnce(kanban, '.o_view_nocontent', + "there should be again a nocontent helper"); + + kanban.destroy(); + }); + + QUnit.test('nocontent helper for grouped kanban with no records with no group_create', async function (assert) { + assert.expect(4); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban group_create="false">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsNone(kanban, '.o_kanban_group', + "there should be no columns"); + assert.containsNone(kanban, '.o_kanban_record', + "there should be no records"); + assert.containsNone(kanban, '.o_view_nocontent', + "there should not be a nocontent helper"); + assert.containsNone(kanban, '.o_column_quick_create', + "there should not be a column quick create"); + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data and no columns', async function (assert) { + assert.expect(3); + + this.data.partner.records = []; + + const kanban = await createView({ + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsNone(kanban, '.o_view_nocontent'); + assert.containsOnce(kanban, '.o_quick_create_unfolded'); + assert.containsOnce(kanban, '.o_kanban_example_background_container'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data and click quick create', async function (assert) { + assert.expect(11); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <t t-name="kanban-box"> + <div><field name="foo"/></div> + </t> + </templates> + </kanban>`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, + "there should be two columns"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_view_nocontent'); + assert.containsN(kanban, '.o_kanban_record', 16, + "there should be 8 sample records by column"); + + await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first')); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record'); + assert.containsNone(kanban, '.o_view_nocontent'); + assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create'); + + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle'); + await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add')); + + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_record'); + assert.containsNone(kanban, '.o_view_nocontent'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data and cancel quick create', async function (assert) { + assert.expect(12); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <t t-name="kanban-box"> + <div><field name="foo"/></div> + </t> + </templates> + </kanban>`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, + "there should be two columns"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_view_nocontent'); + assert.containsN(kanban, '.o_kanban_record', 16, + "there should be 8 sample records by column"); + + await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first')); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record'); + assert.containsNone(kanban, '.o_view_nocontent'); + assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create'); + + await testUtils.dom.click(kanban.$('.o_kanban_view')); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_quick_create'); + assert.containsNone(kanban, '.o_kanban_record'); + assert.containsOnce(kanban, '.o_view_nocontent'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: keyboard navigation', async function (assert) { + assert.expect(5); + + const kanban = await createView({ + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + <field name="state" widget="priority"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + result.groups.forEach(g => g.product_id_count = 0); + } + return result; + }, + }); + + // Check keynav is disabled + assert.hasClass( + kanban.el.querySelector('.o_kanban_record'), + 'o_sample_data_disabled' + ); + assert.hasClass( + kanban.el.querySelector('.o_kanban_toggle_fold'), + 'o_sample_data_disabled' + ); + assert.containsNone(kanban.renderer, '[tabindex]:not([tabindex="-1"])'); + + assert.hasClass(document.activeElement, 'o_searchview_input'); + + await testUtils.fields.triggerKeydown(document.activeElement, 'down'); + + assert.hasClass(document.activeElement, 'o_searchview_input'); + + kanban.destroy(); + }); + + QUnit.test('empty kanban with sample data', async function (assert) { + assert.expect(6); + + this.data.partner.records = []; + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <t t-name="kanban-box"> + <div><field name="foo"/></div> + </t> + </templates> + </kanban>`, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 10, + "there should be 10 sample records"); + assert.containsOnce(kanban, '.o_view_nocontent'); + + await kanban.reload({ domain: [['id', '<', 0]]}); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + assert.containsOnce(kanban, '.o_view_nocontent'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data and many2many_tags', async function (assert) { + assert.expect(6); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <t t-name="kanban-box"> + <div> + <field name="int_field"/> + <field name="category_ids" widget="many2many_tags"/> + </div> + </t> + </templates> + </kanban>`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + assert.step(method || route); + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, "there should be 2 'real' columns"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.ok(kanban.$('.o_kanban_record').length >= 1, "there should be sample records"); + assert.ok(kanban.$('.o_field_many2manytags .o_tag').length >= 1, "there should be tags"); + + assert.verifySteps(["web_read_group"], "should not read the tags"); + kanban.destroy(); + }); + + QUnit.test('sample data does not change after reload with sample data', async function (assert) { + assert.expect(4); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <t t-name="kanban-box"> + <div><field name="int_field"/></div> + </t> + </templates> + </kanban>`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + }); + + const columns = kanban.el.querySelectorAll('.o_kanban_group'); + + assert.ok(columns.length >= 1, "there should be at least 1 sample column"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_record', 16); + + const kanbanText = kanban.el.innerText; + await kanban.reload(); + + assert.strictEqual(kanbanText, kanban.el.innerText, + "the content should be the same after reloading the view"); + + kanban.destroy(); + }); + + QUnit.test('non empty kanban with sample data', async function (assert) { + assert.expect(5); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <t t-name="kanban-box"> + <div><field name="foo"/></div> + </t> + </templates> + </kanban>`, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + assert.containsNone(kanban, '.o_view_nocontent'); + + await kanban.reload({ domain: [['id', '<', 0]]}); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: add a column', async function (assert) { + assert.expect(6); + + const kanban = await createView({ + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + result.groups = this.data.product.records.map(r => { + return { + product_id: [r.id, r.display_name], + product_id_count: 0, + __domain: ['product_id', '=', r.id], + }; + }); + result.length = result.groups.length; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 2); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column')); + await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo"); + await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add')); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 3); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: cannot fold a column', async function (assert) { + // folding a column in grouped kanban with sample data is disabled, for the sake of simplicity + assert.expect(5); + + const kanban = await createView({ + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return a single, empty group + result.groups = result.groups.slice(0, 1); + result.groups[0][`${kwargs.groupby[0]}_count`] = 0; + result.length = 1; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_kanban_group'); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a')); + + assert.hasClass(kanban.el.querySelector('.o_kanban_config .o_kanban_toggle_fold'), 'o_sample_data_disabled'); + assert.hasClass(kanban.el.querySelector('.o_kanban_config .o_kanban_toggle_fold'), 'disabled'); + + kanban.destroy(); + }); + + QUnit.skip('empty grouped kanban with sample data: fold/unfold a column', async function (assert) { + // folding/unfolding of grouped kanban with sample data is currently disabled + assert.expect(8); + + const kanban = await createView({ + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return a single, empty group + result.groups = result.groups.slice(0, 1); + result.groups[0][`${kwargs.groupby[0]}_count`] = 0; + result.length = 1; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_kanban_group'); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // Fold the column + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a')); + await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_kanban_toggle_fold')); + + assert.containsOnce(kanban, '.o_kanban_group'); + assert.hasClass(kanban.$('.o_kanban_group'), 'o_column_folded'); + + // Unfold the column + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group.o_column_folded')); + + assert.containsOnce(kanban, '.o_kanban_group'); + assert.doesNotHaveClass(kanban.$('.o_kanban_group'), 'o_column_folded'); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: delete a column', async function (assert) { + assert.expect(5); + + this.data.partner.records = []; + + let groups = [{ + product_id: [1, 'New'], + product_id_count: 0, + __domain: [], + }]; + const kanban = await createView({ + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { method }) { + let result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return a single, empty group + return { + groups, + length: groups.length, + }; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_kanban_group'); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // Delete the first column + groups = []; + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a')); + await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_column_delete')); + await testUtils.dom.click(document.querySelector('.modal .btn-primary')); + + assert.containsNone(kanban, '.o_kanban_group'); + assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: add a column and delete it right away', async function (assert) { + assert.expect(9); + + const kanban = await createView({ + arch: ` + <kanban sample="1"> + <field name="product_id"/> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + result.groups = this.data.product.records.map(r => { + return { + product_id: [r.id, r.display_name], + product_id_count: 0, + __domain: ['product_id', '=', r.id], + }; + }); + result.length = result.groups.length; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 2); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // add a new column + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column')); + await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo"); + await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add')); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 3); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // delete the column we just created + const newColumn = kanban.el.querySelectorAll('.o_kanban_group')[2]; + await testUtils.dom.click(newColumn.querySelector('.o_kanban_config > a')); + await testUtils.dom.click(newColumn.querySelector('.dropdown-item.o_column_delete')); + await testUtils.dom.click(document.querySelector('.modal .btn-primary')); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 2); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + kanban.destroy(); + }); + + QUnit.test('bounce create button when no data and click on empty area', async function (assert) { + assert.expect(2); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: `<kanban class="o_kanban_test"><templates><t t-name="kanban-box"> + <div> + <t t-esc="record.foo.value"/> + <field name="foo"/> + </div> + </t></templates></kanban>`, + viewOptions: { + action: { + help: '<p class="hello">click to add a partner</p>' + } + }, + }); + + await testUtils.dom.click(kanban.$('.o_kanban_view')); + assert.doesNotHaveClass(kanban.$('.o-kanban-button-new'), 'o_catch_attention'); + + await kanban.reload({ domain: [['id', '<', 0]] }); + + await testUtils.dom.click(kanban.$('.o_kanban_view')); + assert.hasClass(kanban.$('.o-kanban-button-new'), 'o_catch_attention'); + + kanban.destroy(); + }); + + QUnit.test('buttons with modifiers', async function (assert) { + assert.expect(2); + + this.data.partner.records[1].bar = false; // so that test is more complete + + var kanban = await createView({ + View: KanbanView, + model: "partner", + data: this.data, + arch: + '<kanban>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '<field name="state"/>' + + '<templates><div t-name="kanban-box">' + + '<button class="o_btn_test_1" type="object" name="a1" ' + + 'attrs="{\'invisible\': [[\'foo\', \'!=\', \'yop\']]}"/>' + + '<button class="o_btn_test_2" type="object" name="a2" ' + + 'attrs="{\'invisible\': [[\'bar\', \'=\', True]]}" ' + + 'states="abc,def"/>' + + '</div></templates>' + + '</kanban>', + }); + + assert.containsOnce(kanban, ".o_btn_test_1", + "kanban should have one buttons of type 1"); + assert.containsN(kanban, ".o_btn_test_2", 3, + "kanban should have three buttons of type 2"); + kanban.destroy(); + }); + + QUnit.test('button executes action and reloads', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: "partner", + data: this.data, + arch: + '<kanban>' + + '<templates><div t-name="kanban-box">' + + '<field name="foo"/>' + + '<button type="object" name="a1" />' + + '</div></templates>' + + '</kanban>', + mockRPC: function (route) { + assert.step(route); + return this._super.apply(this, arguments); + }, + }); + + assert.ok(kanban.$('button[data-name="a1"]').length, + "kanban should have at least one button a1"); + + var count = 0; + testUtils.mock.intercept(kanban, 'execute_action', function (event) { + count++; + event.data.on_closed(); + }); + await testUtils.dom.click($('button[data-name="a1"]').first()); + assert.strictEqual(count, 1, "should have triggered a execute action"); + + await testUtils.dom.click($('button[data-name="a1"]').first()); + assert.strictEqual(count, 1, "double-click on kanban actions should be debounced"); + + assert.verifySteps([ + '/web/dataset/search_read', + '/web/dataset/call_kw/partner/read' + ], 'a read should be done after the call button to reload the record'); + + kanban.destroy(); + }); + + QUnit.test('button executes action and check domain', async function (assert) { + assert.expect(2); + + var data = this.data; + data.partner.fields.active = {string: "Active", type: "boolean", default: true}; + for (var k in this.data.partner.records) { + data.partner.records[k].active = true; + } + + var kanban = await createView({ + View: KanbanView, + model: "partner", + data: data, + arch: + '<kanban>' + + '<templates><div t-name="kanban-box">' + + '<field name="foo"/>' + + '<field name="active"/>' + + '<button type="object" name="a1" />' + + '<button type="object" name="toggle_active" />' + + '</div></templates>' + + '</kanban>', + }); + + testUtils.mock.intercept(kanban, 'execute_action', function (event) { + data.partner.records[0].active = false; + event.data.on_closed(); + }); + + assert.strictEqual(kanban.$('.o_kanban_record:contains(yop)').length, 1, "should display 'yop' record"); + await testUtils.dom.click(kanban.$('.o_kanban_record:contains(yop) button[data-name="toggle_active"]')); + assert.strictEqual(kanban.$('.o_kanban_record:contains(yop)').length, 0, "should remove 'yop' record from the view"); + + kanban.destroy(); + }); + + QUnit.test('button executes action with domain field not in view', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: "partner", + data: this.data, + domain: [['bar', '=', true]], + arch: + '<kanban>' + + '<templates><div t-name="kanban-box">' + + '<field name="foo"/>' + + '<button type="object" name="a1" />' + + '<button type="object" name="toggle_action" />' + + '</div></templates>' + + '</kanban>', + }); + + testUtils.mock.intercept(kanban, 'execute_action', function (event) { + event.data.on_closed(); + }); + + try { + await testUtils.dom.click(kanban.$('.o_kanban_record:contains(yop) button[data-name="toggle_action"]')); + assert.strictEqual(true, true, 'Everything went fine'); + } catch (e) { + assert.strictEqual(true, false, 'Error triggered at action execution'); + } + kanban.destroy(); + }); + + QUnit.test('rendering date and datetime', async function (assert) { + assert.expect(2); + + this.data.partner.records[0].date = "2017-01-25"; + this.data.partner.records[1].datetime= "2016-12-12 10:55:05"; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="date"/>' + + '<field name="datetime"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<t t-esc="record.date.raw_value"/>' + + '<t t-esc="record.datetime.raw_value"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + }); + + // FIXME: this test is locale dependant. we need to do it right. + assert.strictEqual(kanban.$('div.o_kanban_record:contains(Wed Jan 25)').length, 1, + "should have formatted the date"); + assert.strictEqual(kanban.$('div.o_kanban_record:contains(Mon Dec 12)').length, 1, + "should have formatted the datetime"); + kanban.destroy(); + }); + + QUnit.test('evaluate conditions on relational fields', async function (assert) { + assert.expect(3); + + this.data.partner.records[0].product_id = false; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="product_id"/>' + + '<field name="category_ids"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<button t-if="!record.product_id.raw_value" class="btn_a">A</button>' + + '<button t-if="!record.category_ids.raw_value.length" class="btn_b">B</button>' + + '</div>' + + '</t></templates>' + + '</kanban>', + }); + + assert.strictEqual($('.o_kanban_record:not(.o_kanban_ghost)').length, 4, + "there should be 4 records"); + assert.strictEqual($('.o_kanban_record:not(.o_kanban_ghost) .btn_a').length, 1, + "only 1 of them should have the 'Action' button"); + assert.strictEqual($('.o_kanban_record:not(.o_kanban_ghost) .btn_b').length, 2, + "only 2 of them should have the 'Action' button"); + + kanban.destroy(); + }); + + QUnit.test('resequence columns in grouped by m2o', async function (assert) { + assert.expect(6); + this.data.product.fields.sequence = {string: "Sequence", type: "integer"}; + + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + assert.hasClass(kanban.$('.o_kanban_view'),'ui-sortable', + "columns should be sortable"); + assert.containsN(kanban, '.o_kanban_group', 2, + "should have two columns"); + assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 3, + "first column should be id 3 before resequencing"); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + // there is a 100ms delay on the d&d feature (jquery sortable) for + // kanban columns, making it hard to test. So we rather bypass the d&d + // for this test, and directly call the event handler + envIDs = [2, 4, 1, 3]; // the columns will be inverted + kanban._onResequenceColumn({data: {ids: [5, 3]}}); + await nextTick(); // wait for resequencing before re-rendering + await kanban.update({}, {reload: false}); // re-render without reloading + + assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 5, + "first column should be id 5 after resequencing"); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + kanban.destroy(); + }); + + QUnit.test('properly evaluate more complex domains', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '<field name="category_ids"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div>' + + '<field name="foo"/>' + + '<button type="object" attrs="{\'invisible\':[\'|\', (\'bar\',\'=\',True), (\'category_ids\', \'!=\', [])]}" class="btn btn-primary float-right" name="channel_join_and_get_info">Join</button>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + }); + + assert.containsOnce(kanban, 'button.oe_kanban_action_button', + "only one button should be visible"); + kanban.destroy(); + }); + + QUnit.test('edit the kanban color with the colorpicker', async function (assert) { + assert.expect(5); + + var writeOnColor; + + this.data.category.records[0].color = 12; + + var kanban = await createView({ + View: KanbanView, + model: 'category', + data: this.data, + arch: '<kanban>' + + '<field name="color"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div color="color">' + + '<div class="o_dropdown_kanban dropdown">' + + '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' + + '<span class="fa fa-bars fa-lg"/>' + + '</a>' + + '<ul class="dropdown-menu" role="menu">' + + '<li>' + + '<ul class="oe_kanban_colorpicker"/>' + + '</li>' + + '</ul>' + + '</div>' + + '<field name="name"/>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (args.method === 'write' && 'color' in args.args[1]) { + writeOnColor = true; + } + return this._super.apply(this, arguments); + }, + }); + + var $firstRecord = kanban.$('.o_kanban_record:first()'); + + assert.containsNone(kanban, '.o_kanban_record.oe_kanban_color_12', + "no record should have the color 12"); + assert.strictEqual($firstRecord.find('.oe_kanban_colorpicker').length, 1, + "there should be a color picker"); + assert.strictEqual($firstRecord.find('.oe_kanban_colorpicker').children().length, 12, + "the color picker should have 12 children (the colors)"); + + // Set a color + testUtils.kanban.toggleRecordDropdown($firstRecord); + await testUtils.dom.click($firstRecord.find('.oe_kanban_colorpicker a.oe_kanban_color_9')); + assert.ok(writeOnColor, "should write on the color field"); + $firstRecord = kanban.$('.o_kanban_record:first()'); // First record is reloaded here + assert.ok($firstRecord.is('.oe_kanban_color_9'), + "the first record should have the color 9"); + + kanban.destroy(); + }); + + QUnit.test('load more records in column', async function (assert) { + assert.expect(13); + + var envIDs = [1, 2, 4]; // the ids that should be in the environment during this test + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + viewOptions: { + limit: 2, + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(args.limit + ' - ' + args.offset); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2, + "there should be 2 records in the column"); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + // load more + envIDs = [1, 2, 3, 4]; // id 3 will be loaded + await testUtils.dom.click(kanban.$('.o_kanban_group:eq(1)').find('.o_kanban_load_more')); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3, + "there should now be 3 records in the column"); + assert.verifySteps(['2 - undefined', '2 - undefined', '2 - 2'], + "the records should be correctly fetched"); + assert.deepEqual(kanban.exportState().resIds, envIDs); + + // reload + await kanban.reload(); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3, + "there should still be 3 records in the column after reload"); + assert.deepEqual(kanban.exportState().resIds, envIDs); + assert.verifySteps(['4 - undefined', '2 - undefined']); + + kanban.destroy(); + }); + + QUnit.test('load more records in column with x2many', async function (assert) { + assert.expect(10); + + this.data.partner.records[0].category_ids = [7]; + this.data.partner.records[1].category_ids = []; + this.data.partner.records[2].category_ids = [6]; + this.data.partner.records[3].category_ids = []; + + // record [2] will be loaded after + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="category_ids"/>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + viewOptions: { + limit: 2, + }, + mockRPC: function (route, args) { + if (args.model === 'category' && args.method === 'read') { + assert.step(String(args.args[0])); + } + if (route === '/web/dataset/search_read') { + if (args.limit) { + assert.strictEqual(args.limit, 2, + "the limit should be correctly set"); + } + if (args.offset) { + assert.strictEqual(args.offset, 2, + "the offset should be correctly set at load more"); + } + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2, + "there should be 2 records in the column"); + + assert.verifySteps(['7'], "only the appearing category should be fetched"); + + // load more + await testUtils.dom.click(kanban.$('.o_kanban_group:eq(1)').find('.o_kanban_load_more')); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3, + "there should now be 3 records in the column"); + + assert.verifySteps(['6'], "the other categories should not be fetched"); + + kanban.destroy(); + }); + + QUnit.test('update buttons after column creation', async function (assert) { + assert.expect(2); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['product_id'], + }); + + assert.isNotVisible(kanban.$buttons.find('.o-kanban-button-new'), + "Create button should be hidden"); + + await testUtils.dom.click(kanban.$('.o_column_quick_create')); + kanban.$('.o_column_quick_create input').val('new column'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + assert.isVisible(kanban.$buttons.find('.o-kanban-button-new'), + "Create button should now be visible"); + kanban.destroy(); + }); + + QUnit.test('group_by_tooltip option when grouping on a many2one', async function (assert) { + assert.expect(12); + delete this.data.partner.records[3].product_id; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban default_group_by="bar">' + + '<field name="bar"/>' + + '<field name="product_id" '+ + 'options=\'{"group_by_tooltip": {"name": "Kikou"}}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/product/read') { + assert.strictEqual(args.args[0].length, 2, + "read on two groups"); + assert.deepEqual(args.args[1], ['display_name', 'name'], + "should read on specified fields on the group by relation"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped', + "should have classname 'o_kanban_grouped'"); + assert.containsN(kanban, '.o_kanban_group', 2, "should have " + 2 + " columns"); + + // simulate an update coming from the searchview, with another groupby given + await kanban.update({groupBy: ['product_id']}); + assert.containsN(kanban, '.o_kanban_group', 3, "should have " + 3 + " columns"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 1, + "column should contain 1 record(s)"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 2, + "column should contain 2 record(s)"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(3) .o_kanban_record').length, 1, + "column should contain 1 record(s)"); + assert.ok(kanban.$('.o_kanban_group:first span.o_column_title:contains(Undefined)').length, + "first column should have a default title for when no value is provided"); + assert.ok(!kanban.$('.o_kanban_group:first .o_kanban_header_title').data('original-title'), + "tooltip of first column should not defined, since group_by_tooltip title and the many2one field has no value"); + assert.ok(kanban.$('.o_kanban_group:eq(1) span.o_column_title:contains(hello)').length, + "second column should have a title with a value from the many2one"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_header_title').data('original-title'), + "<div>Kikou</br>hello</div>", + "second column should have a tooltip with the group_by_tooltip title and many2one field value"); + + kanban.destroy(); + }); + + QUnit.test('move a record then put it again in the same column', async function (assert) { + assert.expect(6); + + this.data.partner.records = []; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates></kanban>', + groupBy: ['product_id'], + }); + + await testUtils.dom.click(kanban.$('.o_column_quick_create')); + kanban.$('.o_column_quick_create input').val('column1'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + + await testUtils.dom.click(kanban.$('.o_column_quick_create')); + kanban.$('.o_column_quick_create input').val('column2'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + + await testUtils.dom.click(kanban.$('.o_kanban_group:eq(1) .o_kanban_quick_add i')); + var $quickCreate = kanban.$('.o_kanban_group:eq(1) .o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'new partner'); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 0, + "column should contain 0 record"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 1, + "column should contain 1 records"); + + var $record = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(0)'); + var $group = kanban.$('.o_kanban_group:eq(0)'); + await testUtils.dom.dragAndDrop($record, $group); + await nextTick(); // wait for resequencing after drag and drop + + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 1, + "column should contain 1 records"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 0, + "column should contain 0 records"); + + $record = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'); + $group = kanban.$('.o_kanban_group:eq(1)'); + + await testUtils.dom.dragAndDrop($record, $group); + await nextTick(); // wait for resequencing after drag and drop + + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 0, + "column should contain 0 records"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 1, + "column should contain 1 records"); + kanban.destroy(); + }); + + QUnit.test('resequence a record twice', async function (assert) { + assert.expect(10); + + this.data.partner.records = []; + + var nbResequence = 0; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates></kanban>', + groupBy: ['product_id'], + mockRPC: function (route) { + if (route === '/web/dataset/resequence') { + nbResequence++; + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.dom.click(kanban.$('.o_column_quick_create')); + kanban.$('.o_column_quick_create input').val('column1'); + await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); + + await testUtils.dom.click(kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_add i')); + var $quickCreate = kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'record1'); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + await testUtils.dom.click(kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_add i')); + $quickCreate = kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('input'), 'record2'); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2, + "column should contain 2 records"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record2", + "records should be correctly ordered"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record1", + "records should be correctly ordered"); + + var $record1 = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)'); + var $record2 = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'); + await testUtils.dom.dragAndDrop($record1, $record2, {position: 'top'}); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2, + "column should contain 2 records"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record1", + "records should be correctly ordered"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record2", + "records should be correctly ordered"); + + await testUtils.dom.dragAndDrop($record2, $record1, {position: 'top'}); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2, + "column should contain 2 records"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record2", + "records should be correctly ordered"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record1", + "records should be correctly ordered"); + assert.strictEqual(nbResequence, 2, "should have resequenced twice"); + kanban.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 kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div>' + + '<t t-esc="record.foo.value"/>' + + '<field name="foo" blip="1"/>' + + '<widget name="test"/>' + + '</div>' + + '</t></templates></kanban>', + }); + + assert.strictEqual(kanban.$('.o_widget:eq(2)').text(), '{"foo":"gnap","id":3}', + "widget should have been instantiated"); + + kanban.destroy(); + delete widgetRegistry.map.test; + }); + + QUnit.test('subwidgets with on_attach_callback when changing record color', async function (assert) { + assert.expect(3); + + var counter = 0; + var MyTestWidget = AbstractField.extend({ + on_attach_callback: function () { + counter++; + }, + }); + fieldRegistry.add('test_widget', MyTestWidget); + + var kanban = await createView({ + View: KanbanView, + model: 'category', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="color"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div color="color">' + + '<div class="o_dropdown_kanban dropdown">' + + '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' + + '<span class="fa fa-bars fa-lg"/>' + + '</a>' + + '<ul class="dropdown-menu" role="menu">' + + '<li>' + + '<ul class="oe_kanban_colorpicker"/>' + + '</li>' + + '</ul>' + + '</div>' + + '<field name="name" widget="test_widget"/>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + }); + + // counter should be 2 as there are 2 records + assert.strictEqual(counter, 2, "on_attach_callback should have been called twice"); + + // set a color to kanban record + var $firstRecord = kanban.$('.o_kanban_record:first()'); + testUtils.kanban.toggleRecordDropdown($firstRecord); + await testUtils.dom.click($firstRecord.find('.oe_kanban_colorpicker a.oe_kanban_color_9')); + + // first record has replaced its $el with a new one + $firstRecord = kanban.$('.o_kanban_record:first()'); + assert.hasClass($firstRecord, 'oe_kanban_color_9'); + assert.strictEqual(counter, 3, "on_attach_callback method should be called 3 times"); + + delete fieldRegistry.map.test_widget; + kanban.destroy(); + }); + + QUnit.test('column progressbars properly work', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + assert.containsN(kanban, '.o_kanban_counter', this.data.product.records.length, + "kanban counters should have been created"); + + assert.strictEqual(parseInt(kanban.$('.o_kanban_counter_side').last().text()), 36, + "counter should display the sum of int_field values"); + kanban.destroy(); + }); + + QUnit.test('column progressbars: "false" bar is clickable', async function (assert) { + assert.expect(8); + + this.data.partner.records.push({id: 5, bar: true, foo: false, product_id: 5, state: "ghi"}); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + assert.containsN(kanban, '.o_kanban_group', 2); + assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "4"); + assert.containsN(kanban, '.o_kanban_counter_progress:last .progress-bar', 4); + assert.containsOnce(kanban, '.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]', + "should have false kanban color"); + assert.hasClass(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'), 'bg-muted-full'); + + await testUtils.dom.click(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]')); + + assert.hasClass(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'), 'progress-bar-animated'); + assert.hasClass(kanban.$('.o_kanban_group:last'), 'o_kanban_group_show_muted'); + assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "1"); + + kanban.destroy(); + }); + + QUnit.test('column progressbars: "false" bar with sum_field', async function (assert) { + assert.expect(4); + + this.data.partner.records.push({id: 5, bar: true, foo: false, int_field: 15, product_id: 5, state: "ghi"}); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<field name="foo"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + assert.containsN(kanban, '.o_kanban_group', 2); + assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "51"); + + await testUtils.dom.click(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]')); + + assert.hasClass(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'), 'progress-bar-animated'); + assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "15"); + + kanban.destroy(); + }); + + QUnit.test('column progressbars should not crash in non grouped views', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + mockRPC: function (route, args) { + assert.step(route); + return this._super(route, args); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_record').text(), 'namenamenamename', + "should have renderer 4 records"); + + assert.verifySteps(['/web/dataset/search_read'], "no read on progress bar data is done"); + kanban.destroy(); + }); + + QUnit.test('column progressbars: creating a new column should create a new progressbar', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="product_id"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + var nbProgressBars = kanban.$('.o_kanban_counter').length; + + // Create a new column: this should create an empty progressbar + var $columnQuickCreate = kanban.$('.o_column_quick_create'); + await testUtils.dom.click($columnQuickCreate.find('.o_quick_create_folded')); + $columnQuickCreate.find('input').val('test'); + await testUtils.dom.click($columnQuickCreate.find('.btn-primary')); + + assert.containsN(kanban, '.o_kanban_counter', nbProgressBars + 1, + "a new column with a new column progressbar should have been created"); + + kanban.destroy(); + }); + + QUnit.test('column progressbars on quick create properly update counter', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + var initialCount = parseInt(kanban.$('.o_kanban_counter_side:first').text()); + await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first')); + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'Test'); + await testUtils.dom.click(kanban.$('.o_kanban_add')); + var lastCount = parseInt(kanban.$('.o_kanban_counter_side:first').text()); + await nextTick(); // await update + await nextTick(); // await read + assert.strictEqual(lastCount, initialCount + 1, + "kanban counters should have updated on quick create"); + + kanban.destroy(); + }); + + QUnit.test('column progressbars are working with load more', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + domain: [['bar', '=', true]], + arch: + '<kanban limit="1">' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="id"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + // we have 1 record shown, load 2 more and check it worked + await testUtils.dom.click(kanban.$('.o_kanban_group').find('.o_kanban_load_more')); + await testUtils.dom.click(kanban.$('.o_kanban_group').find('.o_kanban_load_more')); + var shownIDs = _.map(kanban.$('.o_kanban_record'), function(record) { + return parseInt(record.innerText); + }); + assert.deepEqual(shownIDs, [1, 2, 3], "intended records are loaded"); + + kanban.destroy(); + }); + + QUnit.test('column progressbars on archiving records update counter', async function (assert) { + assert.expect(4); + + // add active field on partner model and make all records active + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="active"/>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/action_archive') { + var partnerIDS = args.args[0]; + var records = this.data.partner.records; + _.each(partnerIDS, function(partnerID) { + _.find(records, function (record) { + return record.id === partnerID; + }).active = false; + }); + this.data.partner.records[0].active; + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "36", + "counter should contain the correct value"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "1 yop", + "the counter progressbars should be correctly displayed"); + + // archive all records of the second columns + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:eq(1)')); + await testUtils.dom.click(kanban.$('.o_column_archive_records:visible')); + await testUtils.dom.click($('.modal-footer button:first')); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "0", + "counter should contain the correct value"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "0 yop", + "the counter progressbars should have been correctly updated"); + + kanban.destroy(); + }); + + QUnit.test('kanban with progressbars: correctly update env when archiving records', async function (assert) { + assert.expect(2); + + // add active field on partner model and make all records active + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="active"/>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/action_archive') { + var partnerIDS = args.args[0]; + var records = this.data.partner.records + _.each(partnerIDS, function(partnerID) { + _.find(records, function (record) { + return record.id === partnerID; + }).active = false; + }) + this.data.partner.records[0].active; + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + assert.deepEqual(kanban.exportState().resIds, [1, 2, 3, 4]); + + // archive all records of the first column + testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first')); + await testUtils.dom.click(kanban.$('.o_column_archive_records:visible')); + await testUtils.dom.click($('.modal-footer button:first')); + + assert.deepEqual(kanban.exportState().resIds, [1, 2, 3]); + + kanban.destroy(); + }); + + QUnit.test('RPCs when (re)loading kanban view progressbars', async function (assert) { + assert.expect(9); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + await kanban.reload(); + + assert.verifySteps([ + // initial load + 'web_read_group', + 'read_progress_bar', + '/web/dataset/search_read', + '/web/dataset/search_read', + // reload + 'web_read_group', + 'read_progress_bar', + '/web/dataset/search_read', + '/web/dataset/search_read', + ]); + + kanban.destroy(); + }); + + QUnit.test('drag & drop records grouped by m2o with progressbar', async function (assert) { + assert.expect(4); + + this.data.partner.records[0].product_id = false; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="int_field"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + return Promise.resolve(true); + } + return this._super(route, args); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "1", + "counter should contain the correct value"); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'), kanban.$('.o_kanban_group:eq(1)')); + await nextTick(); // wait for update resulting from drag and drop + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "0", + "counter should contain the correct value"); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(2)'), kanban.$('.o_kanban_group:eq(0)')); + await nextTick(); // wait for update resulting from drag and drop + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "1", + "counter should contain the correct value"); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'), kanban.$('.o_kanban_group:eq(1)')); + await nextTick(); // wait for update resulting from drag and drop + assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "0", + "counter should contain the correct value"); + + kanban.destroy(); + }); + + QUnit.test('progress bar subgroup count recompute', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + var $secondGroup = kanban.$('.o_kanban_group:eq(1)'); + var initialCount = parseInt($secondGroup.find('.o_kanban_counter_side').text()); + assert.strictEqual(initialCount, 3, + "Initial count should be Three"); + await testUtils.dom.click($secondGroup.find('.bg-success-full')); + var lastCount = parseInt($secondGroup.find('.o_kanban_counter_side').text()); + assert.strictEqual(lastCount, 1, + "kanban counters should vary according to what subgroup is selected"); + + kanban.destroy(); + }); + + QUnit.test('column progressbars on quick create with quick_create_view are updated', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="int_field"/>' + + '</form>', + }, + groupBy: ['bar'], + }); + + var initialCount = parseInt(kanban.$('.o_kanban_counter_side:first').text()); + + await testUtils.kanban.clickCreate(kanban); + // fill the quick create and validate + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=int_field]'), '44'); + await testUtils.dom.click($quickCreate.find('button.o_kanban_add')); + + var lastCount = parseInt(kanban.$('.o_kanban_counter_side:first').text()); + assert.strictEqual(lastCount, initialCount + 44, + "kanban counters should have been updated on quick create"); + + kanban.destroy(); + }); + + QUnit.test('keep adding quickcreate in first column after a record from this column was moved', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban on_create="quick_create">' + + '<field name="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + return Promise.resolve(true); + } + return this._super(route, args); + }, + }); + + var $quickCreateGroup; + var $groups; + await _quickCreateAndTest(); + await testUtils.dom.dragAndDrop($groups.first().find('.o_kanban_record:first'), $groups.eq(1)); + await _quickCreateAndTest(); + kanban.destroy(); + + async function _quickCreateAndTest() { + await testUtils.kanban.clickCreate(kanban); + $quickCreateGroup = kanban.$('.o_kanban_quick_create').closest('.o_kanban_group'); + $groups = kanban.$('.o_kanban_group'); + assert.strictEqual($quickCreateGroup[0], $groups[0], + "quick create should have been added in the first column"); + } + }); + + QUnit.test('test displaying image (URL, image field not set)', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="id"/>' + + '<templates><t t-name="kanban-box"><div>' + + '<img t-att-src="kanban_image(\'partner\', \'image\', record.id.raw_value)"/>' + + '</div></t></templates>' + + '</kanban>', + }); + + // since the field image is not set, kanban_image will generate an URL + var imageOnRecord = kanban.$('img[data-src*="/web/image"][data-src*="&id=1"]'); + assert.strictEqual(imageOnRecord.length, 1, "partner with image display image by url"); + + kanban.destroy(); + }); + + QUnit.test('test displaying image (binary & placeholder)', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="id"/>' + + '<field name="image"/>' + + '<templates><t t-name="kanban-box"><div>' + + '<img t-att-src="kanban_image(\'partner\', \'image\', record.id.raw_value)"/>' + + '</div></t></templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (route === 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==') { + assert.ok("The view's image should have been fetched."); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + var images = kanban.el.querySelectorAll('img'); + var placeholders = []; + for (var [index, img] of images.entries()) { + if (img.dataset.src.indexOf(this.data.partner.records[index].image) === -1) { + // Then we display a placeholder + placeholders.push(img); + } + } + + assert.strictEqual(placeholders.length, this.data.partner.records.length - 1, + "partner with no image should display the placeholder"); + + kanban.destroy(); + }); + + QUnit.test('test displaying image (for another record)', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="id"/>' + + '<field name="image"/>' + + '<templates><t t-name="kanban-box"><div>' + + '<img t-att-src="kanban_image(\'partner\', \'image\', 1)"/>' + + '</div></t></templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (route === 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==') { + assert.ok("The view's image should have been fetched."); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + + // the field image is set, but we request the image for a specific id + // -> for the record matching the ID, the base64 should be returned + // -> for all the other records, the image should be displayed by url + var imageOnRecord = kanban.$('img[data-src*="/web/image"][data-src*="&id=1"]'); + assert.strictEqual(imageOnRecord.length, this.data.partner.records.length - 1, + "display image by url when requested for another record"); + + kanban.destroy(); + }); + + QUnit.test("test displaying image from m2o field (m2o field not set)", async function (assert) { + assert.expect(2); + this.data.foo_partner = { + fields: { + name: {string: "Foo Name", type: "char"}, + partner_id: {string: "Partner", type: "many2one", relation: "partner"}, + }, + records: [ + {id: 1, name: 'foo_with_partner_image', partner_id: 1}, + {id: 2, name: 'foo_no_partner'}, + ] + }; + + const kanban = await createView({ + View: KanbanView, + model: "foo_partner", + data: this.data, + arch: ` + <kanban> + <templates> + <div t-name="kanban-box"> + <field name="name"/> + <field name="partner_id"/> + <img t-att-src="kanban_image('partner', 'image', record.partner_id.raw_value)"/> + </div> + </templates> + </kanban>`, + }); + + assert.containsOnce(kanban, 'img[data-src*="/web/image"][data-src$="&id=1"]', "image url should contain id of set partner_id"); + assert.containsOnce(kanban, 'img[data-src*="/web/image"][data-src$="&id="]', "image url should contain an empty id if partner_id is not set"); + + kanban.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: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban string="Partners">' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<field name="qux"/>' + + '<field name="product_id"/>' + + '<field name="category_ids"/>' + + '<field name="state"/>' + + '<field name="date"/>' + + '<field name="datetime"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + }; + + var kanban = await createView(params); + assert.ok(instanceNumber > 0); + + kanban.destroy(); + assert.strictEqual(instanceNumber, 0); + + testUtils.mock.unpatch(mixins.ParentedMixin); + }); + + QUnit.test('grouped kanban becomes ungrouped when clearing domain then clearing groupby', async function (assert) { + // in this test, we simulate that clearing the domain is slow, so that + // clearing the groupby does not corrupt the data handled while + // reloading the kanban view. + assert.expect(4); + + var prom = makeTestPromise(); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + domain: [['foo', '=', 'norecord']], + groupBy: ['bar'], + mockRPC: function (route, args) { + var result = this._super(route, args); + if (args.method === 'web_read_group') { + var isFirstUpdate = _.isEmpty(args.kwargs.domain) && + args.kwargs.groupby && + args.kwargs.groupby[0] === 'bar'; + if (isFirstUpdate) { + return prom.then(function () { + return result; + }); + } + } + return result; + }, + }); + + assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped', + "the kanban view should be grouped"); + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_ungrouped', + "the kanban view should not be ungrouped"); + + kanban.update({domain: []}); // 1st update on kanban view + kanban.update({groupBy: false}); // 2n update on kanban view + prom.resolve(); // simulate slow 1st update of kanban view + + await nextTick(); + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped', + "the kanban view should not longer be grouped"); + assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_ungrouped', + "the kanban view should have become ungrouped"); + + kanban.destroy(); + }); + + QUnit.test('quick_create on grouped kanban without column', async function (assert) { + assert.expect(1); + this.data.partner.records = []; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + // force group_create to false, otherwise the CREATE button in control panel is hidden + arch: '<kanban class="o_kanban_test" group_create="0" on_create="quick_create"><templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates></kanban>', + groupBy: ['product_id'], + + intercepts: { + switch_view: function (event) { + assert.ok(true, "switch_view was called instead of quick_create"); + }, + }, + }); + await testUtils.kanban.clickCreate(kanban); + kanban.destroy(); + }); + + QUnit.test('keyboard navigation on kanban basic rendering', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div>' + + '<t t-esc="record.foo.value"/>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates></kanban>', + }); + + var $fisrtCard = kanban.$('.o_kanban_record:first'); + var $secondCard = kanban.$('.o_kanban_record:eq(1)'); + + $fisrtCard.focus(); + assert.strictEqual(document.activeElement, $fisrtCard[0], "the kanban cards are focussable"); + + $fisrtCard.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT, })); + assert.strictEqual(document.activeElement, $secondCard[0], "the second card should be focussed"); + + $secondCard.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT, keyCode: $.ui.keyCode.LEFT, })); + assert.strictEqual(document.activeElement, $fisrtCard[0], "the first card should be focussed"); + kanban.destroy(); + }); + + QUnit.test('keyboard navigation on kanban grouped rendering', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + var $firstColumnFisrtCard = kanban.$('.o_kanban_record:first'); + var $secondColumnFirstCard = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:first'); + var $secondColumnSecondCard = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(1)'); + + $firstColumnFisrtCard.focus(); + + //RIGHT should select the next column + $firstColumnFisrtCard.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT, })); + assert.strictEqual(document.activeElement, $secondColumnFirstCard[0], "RIGHT should select the first card of the next column"); + + //DOWN should move up one card + $secondColumnFirstCard.trigger($.Event('keydown', { which: $.ui.keyCode.DOWN, keyCode: $.ui.keyCode.DOWN, })); + assert.strictEqual(document.activeElement, $secondColumnSecondCard[0], "DOWN should select the second card of the current column"); + + //LEFT should go back to the first column + $secondColumnSecondCard.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT, keyCode: $.ui.keyCode.LEFT, })); + assert.strictEqual(document.activeElement, $firstColumnFisrtCard[0], "LEFT should select the first card of the first column"); + + kanban.destroy(); + }); + + QUnit.test('keyboard navigation on kanban grouped rendering with empty columns', async function (assert) { + assert.expect(2); + + var data = this.data; + data.partner.records[1].state = "abc"; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: data, + arch: '<kanban class="o_kanban_test">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['state'], + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + return this._super.apply(this, arguments).then(function (result) { + // add 2 empty columns in the middle + result.groups.splice(1, 0, {state_count: 0, state: 'def', + __domain: [["state", "=", "def"]]}); + result.groups.splice(1, 0, {state_count: 0, state: 'def', + __domain: [["state", "=", "def"]]}); + + // add 1 empty column in the beginning and the end + result.groups.unshift({state_count: 0, state: 'def', + __domain: [["state", "=", "def"]]}); + result.groups.push({state_count: 0, state: 'def', + __domain: [["state", "=", "def"]]}); + return result; + }); + } + return this._super.apply(this, arguments); + }, + }); + + /** + * DEF columns are empty + * + * | DEF | ABC | DEF | DEF | GHI | DEF + * |-----|------|-----|-----|------|----- + * | | yop | | | gnap | + * | | blip | | | blip | + */ + var $yop = kanban.$('.o_kanban_record:first'); + var $gnap = kanban.$('.o_kanban_group:eq(4) .o_kanban_record:first'); + + $yop.focus(); + + //RIGHT should select the next column that has a card + $yop.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, + keyCode: $.ui.keyCode.RIGHT, })); + assert.strictEqual(document.activeElement, $gnap[0], + "RIGHT should select the first card of the next column that has a card"); + + //LEFT should go back to the first column that has a card + $gnap.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT, + keyCode: $.ui.keyCode.LEFT, })); + assert.strictEqual(document.activeElement, $yop[0], + "LEFT should select the first card of the first column that has a card"); + + kanban.destroy(); + }); + + QUnit.test('keyboard navigation on kanban when the focus is on a link that ' + + 'has an action and the kanban has no oe_kanban_global_... class', async function (assert) { + assert.expect(1); + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><a type="edit">Edit</a></div>' + + '</t></templates></kanban>', + }); + + testUtils.mock.intercept(kanban, 'switch_view', function (event) { + assert.deepEqual(event.data, { + view_type: 'form', + res_id: 1, + mode: 'edit', + model: 'partner', + }, 'When selecting focusing a card and hitting ENTER, the first link or button is clicked'); + }); + kanban.$('.o_kanban_record').first().focus().trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ENTER, + which: $.ui.keyCode.ENTER, + })); + await testUtils.nextTick(); + + kanban.destroy(); + }); + + QUnit.test('asynchronous rendering of a field widget (ungrouped)', async function (assert) { + assert.expect(4); + + var fooFieldProm = makeTestPromise(); + var FieldChar = fieldRegistry.get('char'); + fieldRegistry.add('asyncwidget', FieldChar.extend({ + willStart: function () { + return fooFieldProm; + }, + start: function () { + this.$el.html('LOADED'); + }, + })); + + var kanbanController; + testUtils.createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><field name="foo" widget="asyncwidget"/></div>' + + '</t></templates></kanban>', + }).then(function (kanban) { + kanbanController = kanban; + }); + + assert.strictEqual($('.o_kanban_record').length, 0, "kanban view is not ready yet"); + + fooFieldProm.resolve(); + await nextTick(); + assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED"); + + // reload with a domain + fooFieldProm = makeTestPromise(); + kanbanController.reload({domain: [['id', '=', 1]]}); + await nextTick(); + + assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED"); + + fooFieldProm.resolve(); + await nextTick(); + assert.strictEqual($('.o_kanban_record').text(), "LOADED"); + + kanbanController.destroy(); + delete fieldRegistry.map.asyncWidget; + }); + + QUnit.test('asynchronous rendering of a field widget (grouped)', async function (assert) { + assert.expect(4); + + var fooFieldProm = makeTestPromise(); + var FieldChar = fieldRegistry.get('char'); + fieldRegistry.add('asyncwidget', FieldChar.extend({ + willStart: function () { + return fooFieldProm; + }, + start: function () { + this.$el.html('LOADED'); + }, + })); + + var kanbanController; + testUtils.createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><field name="foo" widget="asyncwidget"/></div>' + + '</t></templates></kanban>', + groupBy: ['foo'], + }).then(function (kanban) { + kanbanController = kanban; + }); + + assert.strictEqual($('.o_kanban_record').length, 0, "kanban view is not ready yet"); + + fooFieldProm.resolve(); + await nextTick(); + assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED"); + + // reload with a domain + fooFieldProm = makeTestPromise(); + kanbanController.reload({domain: [['id', '=', 1]]}); + await nextTick(); + + assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED"); + + fooFieldProm.resolve(); + await nextTick(); + assert.strictEqual($('.o_kanban_record').text(), "LOADED"); + + kanbanController.destroy(); + delete fieldRegistry.map.asyncWidget; + }); + QUnit.test('asynchronous rendering of a field widget with display attr', async function (assert) { + assert.expect(3); + + var fooFieldDef = makeTestPromise(); + var FieldChar = fieldRegistry.get('char'); + fieldRegistry.add('asyncwidget', FieldChar.extend({ + willStart: function () { + return fooFieldDef; + }, + start: function () { + this.$el.html('LOADED'); + }, + })); + + var kanbanController; + testUtils.createAsyncView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><field name="foo" display="right" widget="asyncwidget"/></div>' + + '</t></templates></kanban>', + }).then(function (kanban) { + kanbanController = kanban; + }); + + assert.containsNone(document.body, '.o_kanban_record'); + + fooFieldDef.resolve(); + await nextTick(); + assert.strictEqual(kanbanController.$('.o_kanban_record').text(), + "LOADEDLOADEDLOADEDLOADED"); + assert.hasClass(kanbanController.$('.o_kanban_record:first .o_field_char'), 'float-right'); + + kanbanController.destroy(); + delete fieldRegistry.map.asyncWidget; + }); + + QUnit.test('asynchronous rendering of a widget', async function (assert) { + assert.expect(2); + + var widgetDef = makeTestPromise(); + widgetRegistry.add('asyncwidget', Widget.extend({ + willStart: function () { + return widgetDef; + }, + start: function () { + this.$el.html('LOADED'); + }, + })); + + var kanbanController; + testUtils.createAsyncView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><widget name="asyncwidget"/></div>' + + '</t></templates></kanban>', + }).then(function (kanban) { + kanbanController = kanban; + }); + + assert.containsNone(document.body, '.o_kanban_record'); + + widgetDef.resolve(); + await nextTick(); + assert.strictEqual(kanbanController.$('.o_kanban_record .o_widget').text(), + "LOADEDLOADEDLOADEDLOADED"); + + kanbanController.destroy(); + delete widgetRegistry.map.asyncWidget; + }); + + QUnit.test('update kanban with asynchronous field widget', async function (assert) { + assert.expect(3); + + var fooFieldDef = makeTestPromise(); + var FieldChar = fieldRegistry.get('char'); + fieldRegistry.add('asyncwidget', FieldChar.extend({ + willStart: function () { + return fooFieldDef; + }, + start: function () { + this.$el.html('LOADED'); + }, + })); + + var kanban = await testUtils.createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><field name="foo" widget="asyncwidget"/></div>' + + '</t></templates></kanban>', + domain: [['id', '=', '0']], // no record matches this domain + }); + + assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + + kanban.update({domain: []}); // this rendering will be async + + assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + + fooFieldDef.resolve(); + await nextTick(); + + assert.strictEqual(kanban.$('.o_kanban_record').text(), + "LOADEDLOADEDLOADEDLOADED"); + + kanban.destroy(); + delete widgetRegistry.map.asyncWidget; + }); + + QUnit.test('set cover image', async function (assert) { + assert.expect(6); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '<div class="o_dropdown_kanban dropdown">' + + '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' + + '<span class="fa fa-bars fa-lg"/>' + + '</a>' + + '<div class="dropdown-menu" role="menu">' + + '<a type="set_cover" data-field="displayed_image_id" class="dropdown-item">Set Cover Image</a>'+ + '</div>' + + '</div>' + + '<div>'+ + '<field name="displayed_image_id" widget="attachment_image"/>'+ + '</div>'+ + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (args.model === 'partner' && args.method === 'write') { + assert.step(String(args.args[0][0])); + return this._super(route, args); + } + return this._super(route, args); + }, + }); + + var $firstRecord = kanban.$('.o_kanban_record:first'); + testUtils.kanban.toggleRecordDropdown($firstRecord); + await testUtils.dom.click($firstRecord.find('[data-type=set_cover]')); + assert.containsNone($firstRecord, 'img', "Initially there is no image."); + + await testUtils.dom.click($('.modal').find("img[data-id='1']")); + await testUtils.modal.clickButton('Select'); + assert.containsOnce(kanban, 'img[data-src*="/web/image/1"]'); + + var $secondRecord = kanban.$('.o_kanban_record:nth(1)'); + testUtils.kanban.toggleRecordDropdown($secondRecord); + await testUtils.dom.click($secondRecord.find('[data-type=set_cover]')); + $('.modal').find("img[data-id='2']").dblclick(); + await testUtils.nextTick(); + assert.containsOnce(kanban, 'img[data-src*="/web/image/2"]'); + assert.verifySteps(["1", "2"], "should writes on both kanban records"); + + kanban.destroy(); + }); + + QUnit.test('ungrouped kanban with handle field', async function (assert) { + assert.expect(4); + + var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="int_field" widget="handle" />' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + assert.deepEqual(args.ids, envIDs, + "should write the sequence in correct order"); + return Promise.resolve(true); + } + return this._super(route, args); + }, + }); + + assert.hasClass(kanban.$('.o_kanban_view'), 'ui-sortable'); + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'yopblipgnapblip'); + + var $record = kanban.$('.o_kanban_view .o_kanban_record:first'); + var $to = kanban.$('.o_kanban_view .o_kanban_record:nth-child(4)'); + envIDs = [2, 3, 4, 1]; // first record of moved after last one + await testUtils.dom.dragAndDrop($record, $to, {position: "bottom"}); + + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'blipgnapblipyop'); + + kanban.destroy(); + }); + + QUnit.test('ungrouped kanban without handle field', async function (assert) { + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + assert.ok(false, "should not trigger a resequencing"); + } + return this._super(route, args); + }, + }); + + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'ui-sortable'); + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'yopblipgnapblip'); + + var $draggedRecord = kanban.$('.o_kanban_view .o_kanban_record:first'); + var $to = kanban.$('.o_kanban_view .o_kanban_record:nth-child(4)'); + await testUtils.dom.dragAndDrop($draggedRecord, $to, {position: "bottom"}); + + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(), + 'yopblipgnapblip'); + + kanban.destroy(); + }); + + QUnit.test('click on image field in kanban with oe_kanban_global_click', async function (assert) { + assert.expect(2); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates><t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<field name="image" widget="image"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + mockRPC: function (route) { + if (route.startsWith('data:image')) { + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + intercepts: { + switch_view: function (event) { + assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), { + mode: 'readonly', + model: 'partner', + res_id: 1, + view_type: 'form', + }, "should trigger an event to open the clicked record in a form view"); + }, + }, + }); + + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + await testUtils.dom.click(kanban.$('.o_field_image').first()); + + kanban.destroy(); + }); + + QUnit.test('kanban view with boolean field', async function (assert) { + assert.expect(2); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div><field name="bar"/></div> + </t> + </templates> + </kanban>`, + }); + + assert.containsN(kanban, '.o_kanban_record:contains(True)', 3); + assert.containsOnce(kanban, '.o_kanban_record:contains(False)'); + + kanban.destroy(); + }); + + QUnit.test('kanban view with boolean widget', async function (assert) { + assert.expect(1); + + const kanban = await testUtils.createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div><field name="bar" widget="boolean"/></div> + </t> + </templates> + </kanban> + `, + }); + + assert.containsOnce(kanban.el.querySelector('.o_kanban_record'), + 'div.custom-checkbox.o_field_boolean'); + kanban.destroy(); + }); + + QUnit.test('kanban view with monetary and currency fields without widget', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <field name="currency_id"/> + <templates><t t-name="kanban-box"> + <div><field name="salary"/></div> + </t></templates> + </kanban>`, + session: { + currencies: _.indexBy(this.data.currency.records, 'id'), + }, + }); + + const kanbanRecords = kanban.el.querySelectorAll('.o_kanban_record:not(.o_kanban_ghost)'); + assert.deepEqual([...kanbanRecords].map(r => r.innerText), + ['$ 1750.00', '$ 1500.00', '2000.00 €', '$ 2222.00']); + + kanban.destroy(); + }); + + QUnit.test("quick create: keyboard navigation to buttons", async function (assert) { + assert.expect(2); + + const kanban = await createView({ + arch: ` + <kanban on_create="quick_create"> + <field name="bar"/> + <templates> + <div t-name="kanban-box"> + <field name="display_name"/> + </div> + </templates> + </kanban>`, + data: this.data, + groupBy: ["bar"], + model: "partner", + View: KanbanView, + }); + + // Open quick create + await testUtils.kanban.clickCreate(kanban); + + assert.containsOnce(kanban, ".o_kanban_group:first .o_kanban_quick_create"); + + const $displayName = kanban.$(".o_kanban_quick_create .o_field_widget[name=display_name]"); + + // Fill in mandatory field + await testUtils.fields.editInput($displayName, "aaa"); + // Tab -> goes to first primary button + await testUtils.dom.triggerEvent($displayName, "keydown", { + keyCode: $.ui.keyCode.TAB, + which: $.ui.keyCode.TAB, + }); + + assert.hasClass(document.activeElement, "btn btn-primary o_kanban_add"); + + kanban.destroy(); + }); +}); + +}); diff --git a/addons/web/static/tests/views/list_benchmarks.js b/addons/web/static/tests/views/list_benchmarks.js new file mode 100644 index 00000000..92d70054 --- /dev/null +++ b/addons/web/static/tests/views/list_benchmarks.js @@ -0,0 +1,113 @@ +odoo.define('web.list_benchmarks', function (require) { + "use strict"; + + const ListView = require('web.ListView'); + const { createView } = require('web.test_utils'); + + QUnit.module('List View', { + beforeEach: function () { + this.data = { + foo: { + fields: { + foo: {string: "Foo", type: "char"}, + bar: {string: "Bar", type: "boolean"}, + int_field: {string: "int_field", type: "integer", sortable: true}, + qux: {string: "my float", type: "float"}, + }, + records: [ + {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4}, + {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13}, + ] + }, + }; + this.arch = null; + this.run = function (assert, cb) { + const data = this.data; + const arch = this.arch; + return new Promise(resolve => { + new Benchmark.Suite({}) + .add('list', { + defer: true, + fn: async (deferred) => { + const list = await createView({ + View: ListView, + model: 'foo', + data, + arch, + }); + if (cb) { + cb(list); + } + list.destroy(); + deferred.resolve(); + }, + }) + .on('cycle', event => { + assert.ok(true, String(event.target)); + }) + .on('complete', resolve) + .run({ async: true }); + }); + }; + } + }, function () { + QUnit.test('simple readonly list with 2 rows and 2 fields', function (assert) { + assert.expect(1); + + this.arch = '<tree><field name="foo"/><field name="int_field"/></tree>'; + return this.run(assert); + }); + + QUnit.test('simple readonly list with 200 rows and 2 fields', function (assert) { + assert.expect(1); + + for (let i = 2; i < 200; i++) { + this.data.foo.records.push({ + id: i, + foo: "automated data", + int_field: 10 * i, + }); + } + this.arch = '<tree><field name="foo"/><field name="int_field"/></tree>'; + return this.run(assert); + }); + + QUnit.test('simple readonly list with 200 rows and 2 fields (with widgets)', function (assert) { + assert.expect(1); + + for (let i = 2; i < 200; i++) { + this.data.foo.records.push({ + id: i, + foo: "automated data", + int_field: 10 * i, + }); + } + this.arch = '<tree><field name="foo" widget="char"/><field name="int_field" widget="integer"/></tree>'; + return this.run(assert); + }); + + QUnit.test('editable list with 200 rows 4 fields', function (assert) { + assert.expect(1); + + for (let i = 2; i < 200; i++) { + this.data.foo.records.push({ + id: i, + foo: "automated data", + int_field: 10 * i, + bar: i % 2 === 0, + }); + } + this.arch = ` + <tree editable="bottom"> + <field name="foo" attrs="{'readonly': [['bar', '=', True]]}"/> + <field name="int_field"/> + <field name="bar"/> + <field name="qux"/> + </tree>`; + return this.run(assert, list => { + list.$('.o_list_button_add').click(); + list.$('.o_list_button_discard').click(); + }); + }); + }); +}); 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 < 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 > \'2017-02-27 12:51:35\' AND datetime < \'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(); + }); +}); + +}); diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js new file mode 100644 index 00000000..9760cfbc --- /dev/null +++ b/addons/web/static/tests/views/pivot_tests.js @@ -0,0 +1,3294 @@ +odoo.define('web.pivot_tests', function (require) { +"use strict"; + +var core = require('web.core'); +var PivotView = require('web.PivotView'); +const PivotController = require("web.PivotController"); +var testUtils = require('web.test_utils'); +var testUtilsDom = require('web.test_utils_dom'); + +var _t = core._t; +const cpHelpers = testUtils.controlPanel; +var createActionManager = testUtils.createActionManager; +var createView = testUtils.createView; +var patchDate = testUtils.mock.patchDate; + +/** + * Helper function that returns, given a pivot instance, the values of the + * table, separated by ','. + * + * @returns {string} + */ +var getCurrentValues = function (pivot) { + return pivot.$('.o_pivot_cell_value div').map(function () { + return $(this).text(); + }).get().join(); +}; + + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "integer", searchable: true, group_operator: 'sum'}, + bar: {string: "bar", type: "boolean", store: true, sortable: true}, + date: {string: "Date", type: "date", store: true, sortable: true}, + product_id: {string: "Product", type: "many2one", relation: 'product', store: true}, + other_product_id: {string: "Other Product", type: "many2one", relation: 'product', store: true}, + non_stored_m2o: {string: "Non Stored M2O", type: "many2one", relation: 'product'}, + customer: {string: "Customer", type: "many2one", relation: 'customer', store: true}, + computed_field: {string: "Computed and not stored", type: 'integer', compute: true, group_operator: 'sum'}, + company_type: { + string: "Company Type", type: "selection", + selection: [["company", "Company"], ["individual", "individual"]], + searchable: true, sortable: true, store: true, + }, + }, + records: [ + { + id: 1, + foo: 12, + bar: true, + date: '2016-12-14', + product_id: 37, + customer: 1, + computed_field: 19, + company_type: 'company', + }, { + id: 2, + foo: 1, + bar: true, + date: '2016-10-26', + product_id: 41, + customer: 2, + computed_field: 23, + company_type: 'individual', + }, { + id: 3, + foo: 17, + bar: true, + date: '2016-12-15', + product_id: 41, + customer: 2, + computed_field: 26, + company_type: 'company', + }, { + id: 4, + foo: 2, + bar: false, + date: '2016-04-11', + product_id: 41, + customer: 1, + computed_field: 19, + company_type: 'individual', + }, + ] + }, + product: { + fields: { + name: {string: "Product Name", type: "char"} + }, + records: [{ + id: 37, + display_name: "xphone", + }, { + id: 41, + display_name: "xpad", + }] + }, + customer: { + fields: { + name: {string: "Customer Name", type: "char"} + }, + records: [{ + id: 1, + display_name: "First", + }, { + id: 2, + display_name: "Second", + }] + }, + }; + }, +}, function () { + QUnit.module('PivotView'); + + QUnit.test('simple pivot rendering', async function (assert) { + assert.expect(3); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function (route, args) { + assert.strictEqual(args.kwargs.lazy, false, + "the read_group should be done with the lazy=false option"); + return this._super.apply(this, arguments); + }, + }); + + assert.hasClass(pivot.$('table'), 'o_enable_linking', + "table should have classname 'o_enable_linking'"); + assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32)').length, 1, + "should contain a pivot cell with the sum of all records"); + pivot.destroy(); + }); + + QUnit.test('pivot rendering with widget', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '<field name="foo" type="measure" widget="float_time"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32:00)').length, 1, + "should contain a pivot cell with the sum of all records"); + pivot.destroy(); + }); + + QUnit.test('pivot rendering with string attribute on field', async function (assert) { + assert.expect(1); + + this.data.partner.fields.foo = {string: "Foo", type: "integer", store: true, group_operator: 'sum'}; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '<field name="foo" string="BAR" type="measure"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('.o_pivot_measure_row').text(), "BAR", + "the displayed name should be the one set in the string attribute"); + pivot.destroy(); + }); + + QUnit.test('pivot rendering with string attribute on non stored field', async function (assert) { + assert.expect(1); + + this.data.partner.fields.fubar = {string: "Fubar", type: "integer", store: false, group_operator: 'sum'}; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '<field name="fubar" string="fubar" type="measure"/>' + + '</pivot>', + }); + assert.containsOnce(pivot, '.o_pivot', 'Non stored fields can have a string attribute'); + pivot.destroy(); + }); + + QUnit.test('pivot rendering with invisible attribute on field', async function (assert) { + assert.expect(3); + // when invisible, a field should neither be an active measure, + // nor be a selectable measure. + _.extend(this.data.partner.fields, { + foo: {string: "Foo", type: "integer", store: true, group_operator: 'sum'}, + foo2: {string: "Foo2", type: "integer", store: true, group_operator: 'sum'} + }); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '<field name="foo" type="measure"/>' + + '<field name="foo2" type="measure" invisible="True"/>' + + '</pivot>', + }); + + // there should be only one displayed measure as the other one is invisible + assert.containsOnce(pivot, '.o_pivot_measure_row'); + // there should be only one measure besides count, as the other one is invisible + assert.containsN(pivot, '.o_cp_bottom_left .dropdown-item', 2); + // the invisible field souldn't be in the groupable fields neither + await testUtils.dom.click(pivot.$('.o_pivot_header_cell_closed:first')); + assert.containsNone(pivot, '.o_pivot_field_menu a[data-field="foo2"]'); + + pivot.destroy(); + }); + + QUnit.test('pivot view without "string" attribute', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + // this is important for export functionality. + assert.strictEqual(pivot.title, _t("Untitled"), "should have a valid title"); + pivot.destroy(); + }); + + QUnit.test('group headers should have a tooltip', async function (assert) { + assert.expect(2); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="col"/>' + + '<field name="date" type="row"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:first').attr('data-original-title'), 'Date'); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:first').attr('data-original-title'), 'Product'); + + pivot.destroy(); + }); + + QUnit.test('pivot view add computed fields explicitly defined as measure', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="computed_field" type="measure"/>' + + '</pivot>', + }); + + assert.ok(pivot.measures.computed_field, "measures contains the field 'computed_field'"); + pivot.destroy(); + }); + + QUnit.test('clicking on a cell triggers a do_action', async function (assert) { + assert.expect(2); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + intercepts: { + do_action: function (ev) { + assert.deepEqual(ev.data.action, { + context: {someKey: true, userContextKey: true}, + domain: [['product_id', '=', 37]], + name: 'Partners', + res_model: 'partner', + target: 'current', + type: 'ir.actions.act_window', + view_mode: 'list', + views: [[false, 'list'], [2, 'form']], + }, "should trigger do_action with the correct args"); + }, + }, + session: { + user_context: {userContextKey: true}, + }, + viewOptions: { + action: { + views: [ + { viewID: 2, type: 'form' }, + { viewID: 5, type: 'kanban' }, + { viewID: false, type: 'list' }, + { viewID: false, type: 'pivot' }, + ], + }, + context: {someKey: true, search_default_test: 3}, + title: 'Partners', + } + }); + + assert.hasClass(pivot.$('table'), 'o_enable_linking', + "table should have classname 'o_enable_linking'"); + await testUtils.dom.click(pivot.$('.o_pivot_cell_value:contains(12)')); // should trigger a do_action + + pivot.destroy(); + }); + + QUnit.test('row and column are highlighted when hovering a cell', async function (assert) { + assert.expect(11); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '<field name="foo" type="col"/>' + + '<field name="product_id" type="row"/>' + + '</pivot>', + }); + + // check row highlighting + assert.hasClass(pivot.$('table'), 'table-hover', + "with className 'table-hover', rows are highlighted (bootstrap)"); + + // check column highlighting + // hover third measure + await testUtils.dom.triggerEvents(pivot.$('th.o_pivot_measure_row:nth(2)'), 'mouseover'); + assert.containsN(pivot, '.o_cell_hover', 3); + for (var i = 0; i < 3; i++) { + assert.hasClass(pivot.$('tbody tr:nth(' + i + ') td:nth(2)'), 'o_cell_hover'); + } + await testUtils.dom.triggerEvents(pivot.$('th.o_pivot_measure_row:nth(2)'), 'mouseout'); + assert.containsNone(pivot, '.o_cell_hover'); + + // hover second cell, second row + await testUtils.dom.triggerEvents(pivot.$('tbody tr:nth(1) td:nth(1)'), 'mouseover'); + assert.containsN(pivot, '.o_cell_hover', 3); + for (i = 0; i < 3; i++) { + assert.hasClass(pivot.$('tbody tr:nth(' + i + ') td:nth(1)'), 'o_cell_hover'); + } + await testUtils.dom.triggerEvents(pivot.$('tbody tr:nth(1) td:nth(1)'), 'mouseout'); + assert.containsNone(pivot, '.o_cell_hover'); + + pivot.destroy(); + }); + + QUnit.test('pivot view with disable_linking="True"', async function (assert) { + assert.expect(2); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot disable_linking="True">' + + '<field name="foo" type="measure"/>' + + '</pivot>', + intercepts: { + do_action: function () { + assert.ok(false, "should not trigger do_action"); + }, + }, + }); + + assert.doesNotHaveClass(pivot.$('table'), 'o_enable_linking', + "table should not have classname 'o_enable_linking'"); + assert.containsOnce(pivot, '.o_pivot_cell_value', + "should have one cell"); + await testUtils.dom.click(pivot.$('.o_pivot_cell_value')); // should not trigger a do_action + + pivot.destroy(); + }); + + QUnit.test('clicking on the "Total" Cell with time range activated gives the right action domain', async function (assert) { + assert.expect(2); + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot/>', + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='this_month'/> + </search> + `, + }, + intercepts: { + do_action: function (ev) { + assert.deepEqual( + ev.data.action.domain, + ["&",["date",">=","2016-12-01"],["date","<=","2016-12-31"]], + "should trigger do_action with the correct action domain" + ); + }, + }, + viewOptions: { + context: { search_default_date_filter: true, }, + title: 'Partners', + }, + }); + + assert.hasClass(pivot.$('table'), 'o_enable_linking', + "root node should have classname 'o_enable_linking'"); + await testUtilsDom.click(pivot.$('.o_pivot_cell_value')); + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('clicking on a fake cell value ("empty group") in comparison mode gives an action domain equivalent to [[0,"=",1]]', async function (assert) { + assert.expect(3); + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + + this.data.partner.records[0].date = '2016-11-15'; + this.data.partner.records[1].date = '2016-11-17'; + this.data.partner.records[2].date = '2016-11-22'; + this.data.partner.records[3].date = '2016-11-03'; + + var first_do_action = true; + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '</pivot>', + intercepts: { + do_action: function (ev) { + if (first_do_action) { + assert.deepEqual( + ev.data.action.domain, + ["&",["date",">=","2016-12-01"],["date","<=","2016-12-31"]], + "should trigger do_action with the correct action domain" + ); + } else { + assert.deepEqual( + ev.data.action.domain, + [[0, "=", 1]], + "should trigger do_action with the correct action domain" + ); + } + first_do_action = false; + }, + }, + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='this_month'/> + </search> + `, + }, + viewOptions: { + context: { search_default_date_filter: true, }, + title: 'Partners', + }, + }); + + await cpHelpers.toggleComparisonMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period'); + + assert.hasClass(pivot.$('table'), 'o_enable_linking', + "root node should have classname 'o_enable_linking'"); + // here we click on the group corresponding to Total/Total/This Month + pivot.$('.o_pivot_cell_value').eq(1).click(); // should trigger a do_action with appropriate domain + // here we click on the group corresponding to xphone/Total/This Month + pivot.$('.o_pivot_cell_value').eq(4).click(); // should trigger a do_action with appropriate domain + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('pivot view grouped by date field', async function (assert) { + assert.expect(2); + + var data = this.data; + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="month" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function (route, params) { + var wrong_fields = _.filter(params.kwargs.fields, function (field) { + return !(field.split(':')[0] in data.partner.fields); + }); + assert.ok(!wrong_fields.length, 'fields given to read_group should exist on the model'); + return this._super.apply(this, arguments); + }, + }); + pivot.destroy(); + }); + + QUnit.test('without measures, pivot view uses __count by default', async function (assert) { + assert.expect(2); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot></pivot>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.fields, ['__count'], + "should make a read_group with no valid fields"); + } + return this._super(route, args); + } + }); + + var $countMeasure = pivot.$buttons.find('.dropdown-item[data-field=__count]:first'); + assert.hasClass($countMeasure, 'selected', "The count measure should be activated"); + pivot.destroy(); + }); + + QUnit.test('pivot view can be reloaded', async function (assert) { + assert.expect(4); + var readGroupCount = 0; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot></pivot>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + readGroupCount++; + } + return this._super(route, args); + } + }); + + assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(4)').length, 1, + "should contain a pivot cell with the number of all records"); + assert.strictEqual(readGroupCount, 1, "should have done 1 rpc"); + + await testUtils.pivot.reload(pivot, {domain: [['foo', '>', 10]]}); + assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(2)').length, 1, + "should contain a pivot cell with the number of remaining records"); + assert.strictEqual(readGroupCount, 2, "should have done 2 rpcs"); + pivot.destroy(); + }); + + QUnit.test('pivot view grouped by many2one field', async function (assert) { + assert.expect(3); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.containsOnce(pivot, '.o_pivot_header_cell_opened', + "should have one opened header"); + assert.strictEqual(pivot.$('.o_pivot_header_cell_closed:contains(xphone)').length, 1, + "should display one header with 'xphone'"); + assert.strictEqual(pivot.$('.o_pivot_header_cell_closed:contains(xpad)').length, 1, + "should display one header with 'xpad'"); + pivot.destroy(); + }); + + QUnit.test('basic folding/unfolding', async function (assert) { + assert.expect(7); + + var rpcCount = 0; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + assert.containsN(pivot, 'tbody tr', 3, + "should have 3 rows: 1 for the opened header, and 2 for data"); + + // click on the opened header to close it + await testUtils.dom.click(pivot.$('.o_pivot_header_cell_opened')); + + assert.containsOnce(pivot, 'tbody tr', "should have 1 row"); + + // click on closed header to open dropdown + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed')); + assert.containsN(pivot, '.o_pivot_field_menu .dropdown-item[data-field="date"]', 6, + "should have the date field as proposition (Date, Day, Week, Month, Quarter and Year)"); + assert.containsOnce(pivot, '.o_pivot_field_menu .dropdown-item[data-field="product_id"]', + "should have the product_id field as proposition"); + assert.containsNone(pivot, '.o_pivot_field_menu .dropdown-item[data-field="non_stored_m2o"]', + "should not have the non_stored_m2o field as proposition"); + + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:first')); + + assert.containsN(pivot, 'tbody tr', 4, + "should have 4 rows: one for header, 3 for data"); + assert.strictEqual(rpcCount, 3, + "should have done 3 rpcs (initial load) + open header with different groupbys"); + + pivot.destroy(); + }); + + QUnit.test('more folding/unfolding', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + // open dropdown to zoom into first row + await testUtils.dom.clickFirst(pivot.$('tbody .o_pivot_header_cell_closed')); + // click on date by day + pivot.$('.dropdown-menu.show .o_inline_dropdown .dropdown-menu').toggle(); // unfold inline dropdown + await testUtils.nextTick(); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:contains("Day")')); + + // open dropdown to zoom into second row + await testUtils.dom.clickLast(pivot.$('tbody th.o_pivot_header_cell_closed')); + + assert.containsN(pivot, 'tbody tr', 7, + "should have 7 rows (1 for total, 1 for xphone, 1 for xpad, 4 for data)"); + + pivot.destroy(); + }); + + QUnit.test('fold and unfold header group', async function (assert) { + assert.expect(3); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.containsN(pivot, 'thead tr', 3); + + // fold opened col group + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_opened')); + assert.containsN(pivot, 'thead tr', 2); + + // unfold it + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]')); + assert.containsN(pivot, 'thead tr', 3); + + pivot.destroy(); + }); + + QUnit.test('unfold second header group', async function (assert) { + assert.expect(4); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.containsN(pivot, 'thead tr', 3); + var values = ['12', '20', '32']; + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + + // unfold it + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="company_type"]')); + assert.containsN(pivot, 'thead tr', 4); + values = ['12', '3', '17', '32']; + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + + pivot.destroy(); + }); + + QUnit.test('can toggle extra measure', async function (assert) { + assert.expect(8); + + var rpcCount = 0; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(pivot, '.o_pivot_cell_value', 3, + "should have 3 cells: 1 for the open header, and 2 for data"); + assert.doesNotHaveClass(pivot.$buttons.find('.dropdown-item[data-field=__count]:first'), 'selected', + "the __count measure should not be selected"); + + rpcCount = 0; + await testUtils.pivot.toggleMeasuresDropdown(pivot); + await testUtils.pivot.clickMeasure(pivot, '__count'); + + assert.hasClass(pivot.$buttons.find('.dropdown-item[data-field=__count]:first'), 'selected', + "the __count measure should be selected"); + assert.containsN(pivot, '.o_pivot_cell_value', 6, + "should have 6 cells: 2 for the open header, and 4 for data"); + assert.strictEqual(rpcCount, 2, + "should have done 2 rpcs to reload data"); + + await testUtils.pivot.clickMeasure(pivot, '__count'); + + assert.doesNotHaveClass(pivot.$buttons.find('.dropdown-item[data-field=__count]:first'), 'selected', + "the __count measure should not be selected"); + assert.containsN(pivot, '.o_pivot_cell_value', 3, + "should have 3 cells: 1 for the open header, and 2 for data"); + assert.strictEqual(rpcCount, 2, + "should not have done any extra rpcs"); + + pivot.destroy(); + }); + + QUnit.test('no content helper when no active measure', async function (assert) { + assert.expect(4); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '</pivot>', + }); + + assert.containsNone(pivot, '.o_view_nocontent', + "should not have a no_content_helper"); + assert.containsOnce(pivot, 'table', + "should have a table in DOM"); + + await testUtils.pivot.toggleMeasuresDropdown(pivot); + await testUtils.pivot.clickMeasure(pivot, '__count'); + + assert.containsOnce(pivot, '.o_view_nocontent', + "should have a no_content_helper"); + assert.containsNone(pivot, 'table', + "should not have a table in DOM"); + pivot.destroy(); + }); + + QUnit.test('no content helper when no data', async function (assert) { + assert.expect(4); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '</pivot>', + }); + + assert.containsNone(pivot, '.o_view_nocontent', + "should not have a no_content_helper"); + assert.containsOnce(pivot, 'table', + "should have a table in DOM"); + + await testUtils.pivot.reload(pivot, {domain: [['foo', '=', 12345]]}); + + assert.containsOnce(pivot, '.o_view_nocontent', + "should have a no_content_helper"); + assert.containsNone(pivot, 'table', + "should not have a table in DOM"); + pivot.destroy(); + }); + + QUnit.test('no content helper when no data, part 2', async function (assert) { + assert.expect(1); + + this.data.partner.records = []; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners"></pivot>', + }); + + assert.containsOnce(pivot, '.o_view_nocontent', + "should have a no_content_helper"); + pivot.destroy(); + }); + + QUnit.test('no content helper when no data, part 3', async function (assert) { + assert.expect(4); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners"></pivot>', + viewOptions: { + domain: [['foo', '=', 12345]] + }, + }); + + assert.containsOnce(pivot, '.o_view_nocontent', + "should have a no_content_helper"); + await testUtils.pivot.reload(pivot, {domain: [['foo', '=', 12345]]}); + assert.containsOnce(pivot, '.o_view_nocontent', + "should still have a no_content_helper"); + await testUtils.pivot.reload(pivot, {domain: []}); + assert.containsNone(pivot, '.o_view_nocontent', + "should not have a no_content_helper"); + + // tries to open a field selection menu, to make sure it was not + // removed from the dom. + await testUtils.dom.clickFirst(pivot.$('.o_pivot_header_cell_closed')); + assert.containsOnce(pivot, '.o_pivot_field_menu', + "the field selector menu exists"); + pivot.destroy(); + }); + + QUnit.test('tries to restore previous state after domain change', async function (assert) { + assert.expect(5); + + var rpcCount = 0; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(pivot, '.o_pivot_cell_value', 3, + "should have 3 cells: 1 for the open header, and 2 for data"); + assert.strictEqual(pivot.$('.o_pivot_measure_row:contains(Foo)').length, 1, + "should have 1 row for measure Foo"); + + await testUtils.pivot.reload(pivot, {domain: [['foo', '=', 12345]]}); + + rpcCount = 0; + await testUtils.pivot.reload(pivot, {domain: []}); + + assert.equal(rpcCount, 2, "should have reloaded data"); + assert.containsN(pivot, '.o_pivot_cell_value', 3, + "should still have 3 cells: 1 for the open header, and 2 for data"); + assert.strictEqual(pivot.$('.o_pivot_measure_row:contains(Foo)').length, 1, + "should still have 1 row for measure Foo"); + pivot.destroy(); + }); + + QUnit.test('can be grouped with the update function', async function (assert) { + assert.expect(4); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.containsOnce(pivot, '.o_pivot_cell_value', + "should have only 1 cell"); + assert.containsOnce(pivot, 'tbody tr', + "should have 1 rows"); + + await testUtils.pivot.reload(pivot, {groupBy: ['product_id']}); + + assert.containsN(pivot, '.o_pivot_cell_value', 3, + "should have 3 cells"); + assert.containsN(pivot, 'tbody tr', 3, + "should have 3 rows"); + pivot.destroy(); + }); + + QUnit.test('can sort data in a column by clicking on header', async function (assert) { + assert.expect(3); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '<field name="product_id" type="row"/>' + + '</pivot>', + }); + + assert.strictEqual($('td.o_pivot_cell_value').text(), "321220", + "should have proper values in cells (total, result 1, result 2"); + + await testUtils.dom.click(pivot.$('th.o_pivot_measure_row')); + + assert.strictEqual($('td.o_pivot_cell_value').text(), "321220", + "should have proper values in cells (total, result 1, result 2"); + + await testUtils.dom.click(pivot.$('th.o_pivot_measure_row')); + + assert.strictEqual($('td.o_pivot_cell_value').text(), "322012", + "should have proper values in cells (total, result 2, result 1"); + + pivot.destroy(); + }); + + QUnit.test('can expand all rows', async function (assert) { + assert.expect(7); + + var nbReadGroups = 0; + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '<field name="product_id" type="row"/>' + + '</pivot>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + nbReadGroups++; + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(nbReadGroups, 2, "should have done 2 read_group RPCS"); + assert.strictEqual(pivot.$('td.o_pivot_cell_value').text(), "321220", + "should have proper values in cells (total, result 1, result 2)"); + + // expand on date:days, product + nbReadGroups = 0; + await testUtils.pivot.reload(pivot, {groupBy: ['date:days', 'product_id']}); + + assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS"); + assert.containsN(pivot, 'tbody tr', 8, + "should have 7 rows (total + 3 for December and 2 for October and April)"); + + // collapse the last two rows + await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened')); + await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened')); + + assert.containsN(pivot, 'tbody tr', 6, + "should have 6 rows now"); + + // expand all + nbReadGroups = 0; + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_expand_button')); + + assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS"); + assert.containsN(pivot, 'tbody tr', 8, + "should have 8 rows again"); + + pivot.destroy(); + }); + + QUnit.test('expand all with a delay', async function (assert) { + assert.expect(3); + + var def; + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '<field name="product_id" type="row"/>' + + '</pivot>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'read_group') { + return Promise.resolve(def).then(_.constant(result)); + } + return result; + }, + }); + + // expand on date:days, product + await testUtils.pivot.reload(pivot, {groupBy: ['date:days', 'product_id']}); + assert.containsN(pivot, 'tbody tr', 8, + "should have 7 rows (total + 3 for December and 2 for October and April)"); + + // collapse the last two rows + await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened')); + await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened')); + + assert.containsN(pivot, 'tbody tr', 6, + "should have 6 rows now"); + + // expand all + def = testUtils.makeTestPromise(); + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_expand_button')); + await testUtils.nextTick(); + def.resolve(); + // await testUtils.returnAfterNextAnimationFrame(); + await testUtils.nextTick(); + assert.containsN(pivot, 'tbody tr', 8, + "should have 8 rows again"); + + pivot.destroy(); + }); + + QUnit.test('can download a file', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="month" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + session: { + get_file: function (args) { + assert.strictEqual(args.url, '/web/pivot/export_xlsx', + "should call get_file with correct parameters"); + args.complete(); + }, + }, + }); + + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_download')); + pivot.destroy(); + }); + + QUnit.test('download a file with single measure, measure row displayed in table', async function (assert) { + assert.expect(1); + + const pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="month" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + session: { + get_file: function (args) { + const data = JSON.parse(args.data.data); + assert.strictEqual(data.measure_headers.length, 4, + "should have measure_headers in data"); + args.complete(); + }, + }, + }); + + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_download')); + pivot.destroy(); + }); + + QUnit.test('download button is disabled when there is no data', async function (assert) { + assert.expect(1); + + this.data.partner.records = []; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="month" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.hasAttrValue(pivot.$buttons.find('.o_pivot_download'), 'disabled', 'disabled', + "download button should be disabled"); + pivot.destroy(); + }); + + QUnit.test('getOwnedQueryParams correctly returns measures and groupbys', async function (assert) { + assert.expect(3); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="day" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['date:day'], + pivot_measures: ['foo'], + pivot_row_groupby: [], + }, + }, "context should be correct"); + + // expand header on field customer + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]:first')); + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['date:day', 'customer'], + pivot_measures: ['foo'], + pivot_row_groupby: [], + }, + }, "context should be correct"); + + // expand row on field product_id + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first')); + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['date:day', 'customer'], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id'], + }, + }, "context should be correct"); + + pivot.destroy(); + }); + + QUnit.test('correctly remove pivot_ keys from the context', async function (assert) { + assert.expect(5); + + this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'}; + + // Equivalent to loading with default filter + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="day" type="col"/>' + + '<field name="amount" type="measure"/>' + + '</pivot>', + viewOptions: { + context: { + pivot_measures: ['foo'], + pivot_column_groupby: ['customer'], + pivot_row_groupby: ['product_id'], + }, + }, + }); + + // Equivalent to unload the filter + var reloadParams = { + context: {}, + }; + await pivot.reload(reloadParams); + + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['customer'], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id'], + }, + }, "context should be correct"); + + // Let's get rid of the rows groupBy + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_opened')); + + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['customer'], + pivot_measures: ['foo'], + pivot_row_groupby: [], + }, + }, "context should be correct"); + + // And now, get rid of the col groupby + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_opened')); + + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: [], + pivot_measures: ['foo'], + pivot_row_groupby: [], + }, + }, "context should be correct"); + + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first')); + + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: [], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id'], + }, + }, "context should be correct"); + + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]:first')); + + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['customer'], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id'], + }, + }, "context should be correct"); + + pivot.destroy(); + }); + + QUnit.test('Unload Filter, reset display, load another filter', async function (assert) { + assert.expect(18); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + viewOptions: { + context: { + pivot_measures: ['foo'], + pivot_column_groupby: ['customer'], + pivot_row_groupby: ['product_id'], + }, + }, + }); + + // Check Columns + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_opened').length, 1, + 'The column should be grouped'); + assert.strictEqual(pivot.$('thead tr:contains("First")').length, 1, + 'There should be a column "First"'); + assert.strictEqual(pivot.$('thead tr:contains("Second")').length, 1, + 'There should be a column "Second"'); + + // Check Rows + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_opened').length, 1, + 'The row should be grouped'); + assert.strictEqual(pivot.$('tbody tr:contains("xphone")').length, 1, + 'There should be a row "xphone"'); + assert.strictEqual(pivot.$('tbody tr:contains("xpad")').length, 1, + 'There should be a row "xpad"'); + + // Equivalent to unload the filter + var reloadParams = { + context: {}, + }; + await pivot.reload(reloadParams); + // collapse all headers + await testUtils.dom.click(pivot.$('.o_pivot_header_cell_opened:first')); + await testUtils.dom.click(pivot.$('.o_pivot_header_cell_opened')); + + // Check Columns + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed').length, 1, + 'The column should not be grouped'); + assert.strictEqual(pivot.$('thead tr:contains("First")').length, 0, + 'There should not be a column "First"'); + assert.strictEqual(pivot.$('thead tr:contains("Second")').length, 0, + 'There should not be a column "Second"'); + + // Check Rows + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed').length, 1, + 'The row should not be grouped'); + assert.strictEqual(pivot.$('tbody tr:contains("xphone")').length, 0, + 'There should not be a row "xphone"'); + assert.strictEqual(pivot.$('tbody tr:contains("xpad")').length, 0, + 'There should not be a row "xpad"'); + + // Equivalent to load another filter + reloadParams = { + context: { + pivot_measures: ['foo'], + pivot_column_groupby: ['customer'], + pivot_row_groupby: ['product_id'], + }, + }; + await pivot.reload(reloadParams); + + // Check Columns + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_opened').length, 1, + 'The column should be grouped'); + assert.strictEqual(pivot.$('thead tr:contains("First")').length, 1, + 'There should be a column "First"'); + assert.strictEqual(pivot.$('thead tr:contains("Second")').length, 1, + 'There should be a column "Second"'); + + // Check Rows + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_opened').length, 1, + 'The row should be grouped'); + assert.strictEqual(pivot.$('tbody tr:contains("xphone")').length, 1, + 'There should be a row "xphone"'); + assert.strictEqual(pivot.$('tbody tr:contains("xpad")').length, 1, + 'There should be a row "xpad"'); + + pivot.destroy(); + }); + + QUnit.test('Reload, group by columns, reload', async function (assert) { + assert.expect(2); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot/>', + }); + + // Set a column groupby + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]')); + + // Set a domain + await pivot.update({domain: [['product_id', '=', 37]], groupBy: [], context: {}}); + + var expectedContext = {pivot_column_groupby: ['customer'], + pivot_measures: ['__count'], + pivot_row_groupby: []}; + + // Check that column groupbys were not lost + assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext}, + 'Column groupby not lost after first reload'); + + // Set a column groupby + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]')); + + // Set a domain + await pivot.update({domain: [['product_id', '=', 41]], groupBy: [], context: {}}); + + expectedContext = {pivot_column_groupby: ['customer', 'product_id'], + pivot_measures: ['__count'], + pivot_row_groupby: []}; + + assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext}, + 'Column groupby not lost after second reload'); + + pivot.destroy(); + }); + + QUnit.test('folded groups remain folded at reload', async function (assert) { + assert.expect(5); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="company_type" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + var values = [ + "29", "3", "32", + "12", "12", + "17", "3", "20", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + + // expand a col group + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]')); + + values = [ + "29", "1", "2", "32", + "12", "12", + "17", "1", "2", "20", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + + // expand a row group + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="other_product_id"]')); + + values = [ + "29", "1", "2", "32", + "12", "12", + "17", "1", "2", "20", + "17", "1", "2", "20", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + + // reload (should keep folded groups folded as col/row groupbys didn't change) + await testUtils.pivot.reload(pivot, {context: {}, domain: [], groupBy: []}); + + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + + await testUtils.dom.click(pivot.$('.o_pivot_expand_button')); + + // sanity check of what the table should look like if all groups are + // expanded, to ensure that the former asserts are pertinent + values = [ + "12", "17", "1", "2", "32", + "12", "12", + "12", "12", + "17", "1", "2", "20", + "17", "1", "2", "20", + ]; + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + + pivot.destroy(); + }); + + QUnit.test('Empty results keep groupbys', async function (assert) { + assert.expect(2); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot/>', + }); + + // Set a column groupby + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]')); + + // Set a domain for empty results + await pivot.update({domain: [['id', '=', false]]}); + + var expectedContext = {pivot_column_groupby: ['customer'], + pivot_measures: ['__count'], + pivot_row_groupby: []}; + assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext}, + 'Column groupby not lost after empty results'); + + // Set a domain for not empty results + await pivot.update({domain: [['product_id', '=', 37]]}); + + assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext}, + 'Column groupby not lost after reload after empty results'); + + pivot.destroy(); + }); + + QUnit.test('correctly uses pivot_ keys from the context', async function (assert) { + assert.expect(7); + + this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'}; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="day" type="col"/>' + + '<field name="amount" type="measure"/>' + + '</pivot>', + viewOptions: { + context: { + pivot_measures: ['foo'], + pivot_column_groupby: ['customer'], + pivot_row_groupby: ['product_id'], + }, + }, + }); + + assert.containsOnce(pivot, 'thead .o_pivot_header_cell_opened', + "column: should have one opened header"); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1, + "column: should display one closed header with 'First'"); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1, + "column: should display one closed header with 'Second'"); + + assert.containsOnce(pivot, 'tbody .o_pivot_header_cell_opened', + "row: should have one opened header"); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1, + "row: should display one closed header with 'xphone'"); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1, + "row: should display one closed header with 'xpad'"); + + assert.strictEqual(pivot.$('tbody tr:first td:nth(2)').text(), '32', + "selected measure should be foo, with total 32"); + + pivot.destroy(); + }); + + QUnit.test('clear table cells data after closeGroup', async function (assert) { + assert.expect(2); + + const pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot/>', + groupBy: ['product_id'], + }); + + await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.el.querySelectorAll('.o_pivot_field_menu .dropdown-item[data-field="date"]')[0]); + + // close and reopen row groupings after changing value + this.data.partner.records.find(r => r.product_id === 37).date = '2016-10-27'; + await testUtils.dom.click(pivot.el.querySelector('tbody .o_pivot_header_cell_opened')); + await testUtils.dom.click(pivot.el.querySelector('tbody .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.el.querySelector('.o_pivot_field_menu .dropdown-item[data-field="product_id"]')); + assert.strictEqual(pivot.el.querySelectorAll('.o_pivot_cell_value')[4].innerText, ''); // xphone December 2016 + + // invert axis, and reopen column groupings + await testUtils.dom.click(pivot.el.querySelector('.o_cp_buttons .o_pivot_flip_button')); + await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_opened')); + await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.el.querySelector('.o_pivot_field_menu .dropdown-item[data-field="product_id"]')); + assert.strictEqual(pivot.el.querySelectorAll('.o_pivot_cell_value')[3].innerText, ''); // December 2016 xphone + + pivot.destroy(); + }); + + QUnit.test('correctly group data after flip (1)', async function (assert) { + assert.expect(4); + const actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,pivot': "<pivot/>", + 'partner,false,search': `<search><filter name="bayou" string="Bayou" domain="[(1,'=',1)]"/></search>`, + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,form': '<form><field name="foo"/></form>', + }, + }); + + await actionManager.doAction({ + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'pivot']], + context: { group_by: ["product_id"] }, + }); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // flip axis + await testUtils.dom.click(actionManager.el.querySelector('.o_cp_buttons .o_pivot_flip_button')); + await testUtils.nextTick(); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + ] + ); + + // select filter "Bayou" in control panel + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleMenuItem(actionManager, "Bayou"); + await testUtils.nextTick(); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // close row header "Total" + await testUtils.dom.click(actionManager.el.querySelector('tbody .o_pivot_header_cell_opened')); + await testUtils.nextTick(); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total" + ] + ); + + actionManager.destroy(); + }); + + QUnit.test('correctly group data after flip (2)', async function (assert) { + assert.expect(5); + const actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,pivot': "<pivot/>", + 'partner,false,search': `<search><filter name="bayou" string="Bayou" domain="[(1,'=',1)]"/></search>`, + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,form': '<form><field name="foo"/></form>', + }, + }); + + await actionManager.doAction({ + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'pivot']], + context: { group_by: ["product_id"] }, + }); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // select filter "Bayou" in control panel + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleMenuItem(actionManager, "Bayou"); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // flip axis + await testUtils.dom.click(actionManager.el.querySelector('.o_cp_buttons .o_pivot_flip_button')); + await testUtils.nextTick(); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total" + ] + ); + + // unselect filter "Bayou" in control panel + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleMenuItem(actionManager, "Bayou"); + await testUtils.nextTick(); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // close row header "Total" + await testUtils.dom.click(actionManager.el.querySelector('tbody .o_pivot_header_cell_opened')); + await testUtils.nextTick(); + + assert.deepEqual( + [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total" + ] + ); + + actionManager.destroy(); + }); + + QUnit.test('correctly group data after flip (3))', async function (assert) { + assert.expect(10); + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: ` + <pivot> + <field name="product_id" type="row"/> + <field name="company_type" type="col"/> + </pivot> + `, + archs: { + 'partner,false,search': `<search><filter name="bayou" string="Bayou" domain="[(1,'=',1)]"/></search>`, + } + }); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText), + [ + "", "Total", "", + "Company", "individual", + "Count", "Count", "Count" + ] + ); + + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // close col header "Total" + await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_opened')); + await testUtils.nextTick(); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText), + [ + "", "Total", + "Count" + ] + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // flip axis + await testUtils.dom.click(pivot.el.querySelector('.o_cp_buttons .o_pivot_flip_button')); + await testUtils.nextTick(); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText), + [ + "", "Total", "", + "xphone", "xpad", + "Count", "Count", "Count" + ] + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total" + ] + ); + + // select filter "Bayou" in control panel + await cpHelpers.toggleFilterMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, "Bayou"); + await testUtils.nextTick(); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText), + [ + "", "Total", "", + "xphone", "xpad", + "Count", "Count", "Count" + ] + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total", + "xphone", + "xpad" + ] + ); + + // close row header "Total" + await testUtils.dom.click(pivot.el.querySelector('tbody .o_pivot_header_cell_opened')); + await testUtils.nextTick(); + + assert.deepEqual( + [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText), + [ + "", "Total", "", + "xphone", "xpad", + "Count", "Count", "Count" + ] + ); + assert.deepEqual( + [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText), + [ + "Total" + ] + ); + + pivot.destroy(); + }); + + QUnit.test('correctly uses pivot_ keys from the context (at reload)', async function (assert) { + assert.expect(8); + + this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'}; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="day" type="col"/>' + + '<field name="amount" type="measure"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('tbody tr:first td.o_pivot_cell_value:last').text(), '0.00', + "the active measure should be amount"); + + var reloadParams = { + context: { + pivot_measures: ['foo'], + pivot_column_groupby: ['customer'], + pivot_row_groupby: ['product_id'], + }, + }; + await testUtils.pivot.reload(pivot, reloadParams); + + assert.containsOnce(pivot, 'thead .o_pivot_header_cell_opened', + "column: should have one opened header"); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1, + "column: should display one closed header with 'First'"); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1, + "column: should display one closed header with 'Second'"); + + assert.containsOnce(pivot, 'tbody .o_pivot_header_cell_opened', + "row: should have one opened header"); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1, + "row: should display one closed header with 'xphone'"); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1, + "row: should display one closed header with 'xpad'"); + + assert.strictEqual(pivot.$('tbody tr:first td:nth(2)').text(), '32', + "selected measure should be foo, with total 32"); + + pivot.destroy(); + }); + + QUnit.test('correctly use group_by key from the context', async function (assert) { + assert.expect(7); + + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="customer" type="col" />' + + '<field name="foo" type="measure" />' + + '</pivot>', + groupBy: ['product_id'], + }); + + assert.containsOnce(pivot, 'thead .o_pivot_header_cell_opened', + 'column: should have one opened header'); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1, + 'column: should display one closed header with "First"'); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1, + 'column: should display one closed header with "Second"'); + + assert.containsOnce(pivot, 'tbody .o_pivot_header_cell_opened', + 'row: should have one opened header'); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1, + 'row: should display one closed header with "xphone"'); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1, + 'row: should display one closed header with "xpad"'); + + assert.strictEqual(pivot.$('tbody tr:first td:nth(2)').text(), '32', + 'selected measure should be foo, with total 32'); + + pivot.destroy(); + }); + + QUnit.test('correctly uses pivot_row_groupby key with default groupBy from the context', async function (assert) { + assert.expect(6); + + this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'}; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="customer" type="col"/>' + + '<field name="date" interval="day" type="row"/>' + + '</pivot>', + groupBy: ['customer'], + viewOptions: { + context: { + pivot_row_groupby: ['product_id'], + }, + }, + }); + + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_opened').length, 1, + "column: should have one opened header"); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1, + "column: should display one closed header with 'First'"); + assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1, + "column: should display one closed header with 'Second'"); + + // With pivot_row_groupby, groupBy customer should replace and eventually display product_id + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_opened').length, 1, + "row: should have one opened header"); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1, + "row: should display one closed header with 'xphone'"); + assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1, + "row: should display one closed header with 'xpad'"); + + pivot.destroy(); + }); + + QUnit.test('pivot still handles __count__ measure', async function (assert) { + // for retro-compatibility reasons, the pivot view still handles + // '__count__' measure. + assert.expect(2); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot></pivot>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.fields, ['__count'], + "should make a read_group with field __count"); + } + return this._super(route, args); + }, + viewOptions: { + context: { + pivot_measures: ['__count__'], + }, + }, + }); + + var $countMeasure = pivot.$buttons.find('.dropdown-item[data-field=__count]:first'); + assert.hasClass($countMeasure,'selected', "The count measure should be activated"); + + pivot.destroy(); + }); + + QUnit.test('not use a many2one as a measure by default', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id"/>' + + '<field name="date" interval="month" type="col"/>' + + '</pivot>', + }); + assert.notOk(pivot.measures.product_id, + "should not have product_id as measure"); + pivot.destroy(); + }); + + QUnit.test('use a many2one as a measure with specified additional measure', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id"/>' + + '<field name="date" interval="month" type="col"/>' + + '</pivot>', + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + assert.ok(pivot.measures.product_id, + "should have product_id as measure"); + pivot.destroy(); + }); + + QUnit.test('pivot view with many2one field as a measure', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="measure"/>' + + '<field name="date" interval="month" type="col"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('table tbody tr').text().trim(), "Total2112", + "should display product_id count as measure"); + pivot.destroy(); + }); + + QUnit.test('m2o as measure, drilling down into data', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="measure"/>' + + '</pivot>', + }); + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed')); + // click on date by month + pivot.$('.dropdown-menu.show .o_inline_dropdown .dropdown-menu').toggle(); // unfold inline dropdown + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:contains("Month")')); + + assert.strictEqual(pivot.$('.o_pivot_cell_value').text(), '2211', + 'should have loaded the proper data'); + pivot.destroy(); + }); + + QUnit.test('pivot view with same many2one field as a measure and grouped by', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '</pivot>', + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + + await testUtils.pivot.toggleMeasuresDropdown(pivot); + await testUtils.pivot.clickMeasure(pivot, 'product_id'); + assert.strictEqual(pivot.$('.o_pivot_cell_value').text(), '421131', + 'should have loaded the proper data'); + pivot.destroy(); + }); + + QUnit.test('pivot view with same many2one field as a measure and grouped by (and drill down)', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="measure"/>' + + '</pivot>', + }); + + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed')); + + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first')); + + assert.strictEqual(pivot.$('.o_pivot_cell_value').text(), '211', + 'should have loaded the proper data'); + pivot.destroy(); + }); + + QUnit.test('Row and column groupbys plus a domain', async function (assert) { + assert.expect(3); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + // Set a column groupby + await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]:first')); + + // Set a Row groupby + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first')); + + // Set a domain + await testUtils.pivot.reload(pivot, {domain: [['product_id', '=', 41]]}); + + var expectedContext = { + context: { + pivot_column_groupby: ['customer'], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id'], + }, + }; + + // Mock 'save as favorite' + assert.deepEqual(pivot.getOwnedQueryParams(), expectedContext, + 'The pivot view should have the right context'); + + var $xpadHeader = pivot.$('tbody .o_pivot_header_cell_closed[data-original-title=Product]'); + assert.equal($xpadHeader.length, 1, + 'There should be only one product line because of the domain'); + + assert.equal($xpadHeader.text(), 'xpad', + 'The product should be the right one'); + + pivot.destroy(); + }); + + QUnit.test('parallel data loading should discard all but the last one', async function (assert) { + assert.expect(2); + + var def; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'read_group') { + return Promise.resolve(def).then(_.constant(result)); + } + return result; + }, + }); + + def = testUtils.makeTestPromise(); + testUtils.pivot.reload(pivot, {groupBy: ['product_id']}); + testUtils.pivot.reload(pivot, {groupBy: ['product_id', 'customer']}); + await def.resolve(); + await testUtils.nextTick(); + assert.containsN(pivot, '.o_pivot_cell_value', 6, + "should have 6 cells"); + assert.containsN(pivot, 'tbody tr', 6, + "should have 6 rows"); + pivot.destroy(); + }); + + QUnit.test('pivot measures should be alphabetically sorted', async function (assert) { + assert.expect(2); + + var data = this.data; + // It's important to compare capitalized and lowercased words + // to be sure the sorting is effective with both of them + data.partner.fields.bouh = {string: "bouh", type: "integer", group_operator: 'sum'}; + data.partner.fields.modd = {string: "modd", type: "integer", group_operator: 'sum'}; + data.partner.fields.zip = {string: "Zip", type: "integer", group_operator: 'sum'}; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: data, + arch: '<pivot>' + + '<field name="zip" type="measure"/>' + + '<field name="foo" type="measure"/>' + + '<field name="bouh" type="measure"/>' + + '<field name="modd" type="measure"/>' + + '</pivot>', + }); + assert.strictEqual(pivot.$buttons.find('.o_pivot_measures_list .dropdown-item:first').data('field'), 'bouh', + "Bouh should be the first measure"); + assert.strictEqual(pivot.$buttons.find('.o_pivot_measures_list .dropdown-item:last').data('field'), '__count', + "Count should be the last measure"); + + pivot.destroy(); + }); + + QUnit.test('pivot view should use default order for auto sorting', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot default_order="foo asc">' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.hasClass(pivot.$('thead tr:last th:last'), 'o_pivot_sort_order_asc', + "Last thead should be sorted in ascending order"); + + pivot.destroy(); + }); + + QUnit.test('pivot view can be flipped', async function (assert) { + assert.expect(5); + + var rpcCount = 0; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '</pivot>', + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(pivot, 'tbody tr', 3, + "should have 3 rows: 1 for the open header, and 2 for data"); + var values = [ + "4", + "1", + "3" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + rpcCount = 0; + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button')); + + assert.strictEqual(rpcCount, 0, "should not have done any rpc"); + assert.containsOnce(pivot, 'tbody tr', + "should have 1 rows: 1 for the main header"); + + values = [ + "1", "3", "4" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + pivot.destroy(); + }); + + QUnit.test('rendering of pivot view with comparison', async function (assert) { + assert.expect(8); + + this.data.partner.records[0].date = '2016-12-15'; + this.data.partner.records[1].date = '2016-12-17'; + this.data.partner.records[2].date = '2016-11-22'; + this.data.partner.records[3].date = '2016-11-03'; + + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="month" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='last_year'/> + </search> + `, + }, + viewOptions: { + additionalMeasures: ['product_id'], + context: { search_default_date_filter: 1 }, + }, + mockRPC: function () { + return this._super.apply(this, arguments); + }, + env: { + dataManager: { + create_filter: async function (filter) { + assert.deepEqual(filter.context, { + pivot_measures: ['__count'], + pivot_column_groupby: [], + pivot_row_groupby: ['product_id'], + group_by: [], + comparison: { + comparisonId: "previous_period", + comparisonRange: "[\"&\", [\"date\", \">=\", \"2016-11-01\"], [\"date\", \"<=\", \"2016-11-30\"]]", + comparisonRangeDescription: "November 2016", + fieldDescription: "Date", + fieldName: "date", + range: "[\"&\", [\"date\", \">=\", \"2016-12-01\"], [\"date\", \"<=\", \"2016-12-31\"]]", + rangeDescription: "December 2016" + }, + }); + } + } + }, + }); + + // with no data + await cpHelpers.toggleComparisonMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period'); + + assert.strictEqual(pivot.$('.o_pivot p.o_view_nocontent_empty_folder').length, 1); + + await cpHelpers.toggleFilterMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date'); + await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'December'); + await cpHelpers.toggleMenuItemOption(pivot, 'Date', '2016'); + await cpHelpers.toggleMenuItemOption(pivot, 'Date', '2015'); + + assert.containsN(pivot, '.o_pivot thead tr:last th', 9, + "last header row should contains 9 cells (3*[December 2016, November 2016, Variation]"); + var values = [ + "19", "0", "-100%", "0", "13", "100%", "19", "13", "-31.58%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // with data, with row groupby + await testUtils.dom.click(pivot.$('.o_pivot .o_pivot_header_cell_closed').eq(2)); + await testUtils.dom.click(pivot.$('.o_pivot .o_pivot_field_menu a[data-field="product_id"]')); + values = [ + "19", "0", "-100%", "0", "13", "100%", "19", "13", "-31.58%", + "19", "0", "-100%", "0", "1" , "100%", "19", "1", "-94.74%", + "0", "12", "100%", "0" , "12", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await testUtils.pivot.toggleMeasuresDropdown(pivot); + await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="foo"]')); + await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="product_id"]')); + values = [ + "1", "0", "-100%", "0", "2", "100%", "1", "2", "100%", + "1", "0", "-100%", "0", "1", "100%", "1", "1", "0%", + "0", "1", "100%", "0", "1", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="__count"]')); + await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="product_id"]')); + values = [ + "2", "0", "-100%", "0", "2", "100%", "2", "2", "0%", + "2", "0", "-100%", "0", "1", "100%", "2", "1", "-50%", + "0", "1", "100%", "0", "1", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await testUtils.dom.clickFirst(pivot.$('.o_pivot .o_pivot_header_cell_opened')); + values = [ + "2", "2", "0%", + "2", "1", "-50%", + "0", "1", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + await cpHelpers.toggleFavoriteMenu(pivot); + await cpHelpers.toggleSaveFavorite(pivot); + await cpHelpers.editFavoriteName(pivot, 'Fav'); + await cpHelpers.saveFavorite(pivot); + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('export data in excel with comparison', async function (assert) { + assert.expect(11); + + this.data.partner.records[0].date = '2016-12-15'; + this.data.partner.records[1].date = '2016-12-17'; + this.data.partner.records[2].date = '2016-11-22'; + this.data.partner.records[3].date = '2016-11-03'; + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="month" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='antepenultimate_month'/> + </search> + `, + }, + viewOptions: { + context: { search_default_date_filter: 1 }, + }, + session: { + get_file: function (args) { + var data = JSON.parse(args.data.data); + _.each(data.col_group_headers, function (l) { + var titles = l.map(function (o) { + return o.title; + }); + assert.step(JSON.stringify(titles)); + }); + var measures = data.measure_headers.map(function (o) { + return o.title; + }); + assert.step(JSON.stringify(measures)); + var origins = data.origin_headers.map(function (o) { + return o.title; + }); + assert.step(JSON.stringify(origins)); + assert.step(String(data.measure_count)); + assert.step(String(data.origin_count)); + var valuesLength = data.rows.map(function (o) { + return o.values.length; + }); + assert.step(JSON.stringify(valuesLength)); + assert.strictEqual(args.url, '/web/pivot/export_xlsx', + "should call get_file with correct parameters"); + args.complete(); + }, + }, + }); + + // open comparison menu + await cpHelpers.toggleComparisonMenu(pivot); + // compare October 2016 to September 2016 + await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period'); + + // With the data above, the time ranges contain no record. + assert.strictEqual(pivot.$('.o_pivot p.o_view_nocontent_empty_folder').length, 1, "there should be no data"); + // export data should be impossible since the pivot buttons + // are deactivated (exception: the 'Measures' button). + assert.ok(pivot.$('.o_control_panel button.o_pivot_download').prop('disabled')); + + await cpHelpers.toggleFilterMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date'); + await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'December'); + await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'October'); + + // With the data above, the time ranges contain some records. + // export data. Should execute 'get_file' + await testUtils.dom.click(pivot.$('.o_control_panel button.o_pivot_download')); + + assert.verifySteps([ + // col group headers + '["Total",""]', + '["November 2016","December 2016"]', + // measure headers + '["Foo","Foo","Foo"]', + // origin headers + '["November 2016","December 2016","Variation","November 2016","December 2016"' + + ',"Variation","November 2016","December 2016","Variation"]', + // number of 'measures' + '1', + // number of 'origins' + '2', + // rows values length + '[9]', + ]); + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('rendering of pivot view with comparison and count measure', async function (assert) { + assert.expect(2); + + var mockMock = false; + var nbReadGroup = 0; + + this.data.partner.records[0].date = '2016-12-15'; + this.data.partner.records[1].date = '2016-12-17'; + this.data.partner.records[2].date = '2016-12-22'; + this.data.partner.records[3].date = '2016-12-03'; + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot><field name="customer" type="row"/></pivot>', + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='this_month'/> + </search> + `, + }, + viewOptions: { + context: { search_default_date_filter: 1 }, + }, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'read_group' && mockMock) { + nbReadGroup++; + if (nbReadGroup === 4) { + // this modification is necessary because mockReadGroup does not + // properly reflect the server response when there is no record + // and a groupby list of length at least one. + return Promise.resolve([{}]); + } + } + return result; + }, + }); + + mockMock = true; + + // compare December 2016 to November 2016 + await cpHelpers.toggleComparisonMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period'); + + var values = [ + "0", "4", "100%", + "0", "2", "100%", + "0", "2", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join(',')); + assert.strictEqual(pivot.$('.o_pivot_header_cell_closed').length, 3, + "there should be exactly three closed header ('Total','First', 'Second')"); + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('can sort a pivot view with comparison by clicking on header', async function (assert) { + assert.expect(6); + + this.data.partner.records[0].date = '2016-12-15'; + this.data.partner.records[1].date = '2016-12-17'; + this.data.partner.records[2].date = '2016-11-22'; + this.data.partner.records[3].date = '2016-11-03'; + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="day" type="row"/>' + + '<field name="company_type" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='this_month'/> + </search> + `, + }, + viewOptions: { + additionalMeasures: ['product_id'], + context: { search_default_date_filter: 1 }, + }, + }); + + // compare December 2016 to November 2016 + await cpHelpers.toggleComparisonMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period'); + + // initial sanity check + var values = [ + "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%", + "17", "0", "-100%", "17", "0", "-100%", + "2", "0", "-100%", "2", "0", "-100%", + "0", "12" , "100%", "0", "12" , "100%", + "0", "1", "100%" , "0" , "1" , "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'Foo' in column Total/Company (should sort by the period of interest, ASC) + await testUtils.dom.click(pivot.$('.o_pivot_measure_row').eq(0)); + values = [ + "17", "12", "-29.41%", "2", "1", "-50%" , "19", "13", "-31.58%", + "2", "0", "-100%", "2", "0", "-100%", + "0", "12", "100%", "0", "12", "100%", + "0", "1", "100%", "0", "1", "100%", + "17", "0", "-100%", "17", "0", "-100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click again on 'Foo' in column Total/Company (should sort by the period of interest, DESC) + await testUtils.dom.click(pivot.$('.o_pivot_measure_row').eq(0)); + values = [ + "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%", + "17", "0", "-100%", "17", "0", "-100%", + "2", "0", "-100%", "2", "0", "-100%", + "0", "12", "100%", "0", "12", "100%", + "0", "1", "100%", "0", "1", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'This Month' in column Total/Individual/Foo + await testUtils.dom.click(pivot.$('.o_pivot_origin_row').eq(3)); + values = [ + "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%", + "17", "0", "-100%", "17", "0", "-100%", + "0", "12", "100%", "0", "12" , "100%", + "0", "1", "100%", "0", "1", "100%", + "2", "0", "-100%", "2", "0", "-100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'Previous Period' in column Total/Individual/Foo + await testUtils.dom.click(pivot.$('.o_pivot_origin_row').eq(4)); + values = [ + "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%", + "17", "0", "-100%", "17", "0", "-100%", + "2", "0", "-100%", "2", "0", "-100%", + "0", "12", "100%", "0", "12", "100%", + "0", "1", "100%", "0", "1", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // click on 'Variation' in column Total/Foo + await testUtils.dom.click(pivot.$('.o_pivot_origin_row').eq(8)); + values = [ + "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%", + "17", "0", "-100%", "17", "0", "-100%", + "2", "0", "-100%", "2" , "0" , "-100%", + "0", "12", "100%", "0", "12" , "100%", + "0", "1", "100%", "0" , "1", "100%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('Click on the measure list but not on a menu item', async function (assert) { + assert.expect(2); + + const pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: `<pivot/>`, + }); + + // open the "Measures" menu + await testUtils.dom.click(pivot.el.querySelector('.o_cp_buttons button')); + + // click on the divider in the "Measures" menu does not crash + await testUtils.dom.click(pivot.el.querySelector('.o_pivot_measures_list .dropdown-divider')); + // the menu should still be open + assert.isVisible(pivot.el.querySelector('.o_pivot_measures_list')); + + // click on the measure list but not on a menu item or the separator + await testUtils.dom.click(pivot.el.querySelector('.o_pivot_measures_list')); + // the menu should still be open + assert.isVisible(pivot.el.querySelector('.o_pivot_measures_list')); + + pivot.destroy(); + }); + + QUnit.test('Navigation list view for a group and back with breadcrumbs', async function (assert) { + assert.expect(16); + // create an action manager to test the interactions with the search view + var readGroupCount = 0; + + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,pivot': '<pivot>' + + '<field name="customer" type="row"/>' + + '</pivot>', + 'partner,false,search': '<search><filter name="bayou" string="Bayou" domain="[(\'foo\',\'=\', 12)]"/></search>', + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,form': '<form><field name="foo"/></form>', + }, + intercepts: { + do_action: function (event) { + var action = event.data.action; + actionManager.doAction(action); + } + }, + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.step('read_group'); + const domain = args.kwargs.domain; + if ([0,1].indexOf(readGroupCount) !== -1) { + assert.deepEqual(domain, [], 'domain empty'); + } else if ([2,3,4,5].indexOf(readGroupCount) !== -1) { + assert.deepEqual(domain, [['foo', '=', 12]], + 'domain conserved when back with breadcrumbs'); + } + readGroupCount++; + } + if (route === '/web/dataset/search_read') { + assert.step('search_read'); + const domain = args.domain; + assert.deepEqual(domain, ['&', ['customer', '=', 1], ['foo', '=', 12]], + 'list domain is correct'); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction({ + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'pivot']], + }); + + + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleMenuItem(actionManager, 0); + await testUtils.nextTick(); + + await testUtilsDom.click(actionManager.$('.o_pivot_cell_value:nth(1)')); + await testUtils.nextTick(); + + assert.containsOnce(actionManager, '.o_list_view'); + + await testUtilsDom.click(actionManager.$('.o_control_panel ol.breadcrumb li.breadcrumb-item').eq(0)); + + assert.verifySteps([ + 'read_group', 'read_group', + 'read_group', 'read_group', + 'search_read', + 'read_group', 'read_group']); + actionManager.destroy(); + }); + + QUnit.test('Cell values are kept when flippin a pivot view in comparison mode', async function (assert) { + assert.expect(2); + + this.data.partner.records[0].date = '2016-12-15'; + this.data.partner.records[1].date = '2016-12-17'; + this.data.partner.records[2].date = '2016-11-22'; + this.data.partner.records[3].date = '2016-11-03'; + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="day" type="row"/>' + + '<field name="company_type" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='this_month'/> + </search> + `, + }, + viewOptions: { + additionalMeasures: ['product_id'], + context: { search_default_date_filter: 1 }, + }, + }); + + // compare December 2016 to November 2016 + await cpHelpers.toggleComparisonMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period'); + + // initial sanity check + var values = [ + "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%", + "17", "0", "-100%", "17", "0", "-100%", + "2", "0", "-100%", "2", "0", "-100%", + "0", "12", "100%", "0", "12", "100%", + "0", "1", "100%", "0", "1", "100%", + + + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + // flip table + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button')); + + values = [ + "17", "0", "-100%", "2", "0", "-100%", "0", "12", "100%", "0", "1", "100%", "19", "13", "-31.58%", + "17", "0", "-100%", "0", "12", "100%", "17", "12", "-29.41%", + "2", "0", "-100%", "0", "1", "100%", "2", "1", "-50%" + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('Flip then compare, table col groupbys are kept', async function (assert) { + assert.expect(6); + + this.data.partner.records[0].date = '2016-12-15'; + this.data.partner.records[1].date = '2016-12-17'; + this.data.partner.records[2].date = '2016-11-22'; + this.data.partner.records[3].date = '2016-11-03'; + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="date" interval="day" type="row"/>' + + '<field name="company_type" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + archs: { + 'partner,false,search': ` + <search> + <filter name="date_filter" date="date" domain="[]" default_period='this_month'/> + </search> + `, + }, + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + + + assert.strictEqual( + pivot.$('th').slice(0, 5).text(), + [ + '', 'Total', '', + 'Company', 'individual', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(8).text(), + [ + 'Total', + '2016-12-15', + '2016-12-17', + '2016-11-22', + '2016-11-03' + ].join(''), + "The row headers should be as expected" + ); + + // flip + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button')); + + assert.strictEqual( + pivot.$('th').slice(0, 7).text(), + [ + '', 'Total', '', + '2016-12-15', '2016-12-17', '2016-11-22', '2016-11-03', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(12).text(), + [ + 'Total', + 'Company', + 'individual' + + ].join(''), + "The row headers should be as expected" + ); + + // Filter on December 2016 + await cpHelpers.toggleFilterMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date'); + await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'December'); + + // compare December 2016 to November 2016 + await cpHelpers.toggleComparisonMenu(pivot); + await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period'); + + assert.strictEqual( + pivot.$('th').slice(0, 7).text(), + [ + '', 'Total', '', + '2016-11-22', '2016-11-03', '2016-12-15', '2016-12-17', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(27).text(), + [ + 'Total', + 'Company', + 'individual' + + ].join(''), + "The row headers should be as expected" + ); + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('correctly compute group domain when a date field has false value', async function (assert) { + assert.expect(1); + + this.data.partner.records.forEach(r => r.date = false); + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot o_enable_linking="1">' + + '<field name="date" interval="day" type="row"/>' + + '</pivot>', + intercepts: { + do_action: function (ev) { + assert.deepEqual(ev.data.action.domain, [['date', '=', false]]); + }, + }, + }); + + await testUtils.dom.click($('div .o_value')[1]); + + unpatchDate(); + pivot.destroy(); + }); + QUnit.test('Does not identify "false" with false as keys when creating group trees', async function (assert) { + assert.expect(2); + + this.data.partner.fields.favorite_animal = {string: "Favorite animal", type: "char", store: true}; + this.data.partner.records[0].favorite_animal = 'Dog'; + this.data.partner.records[1].favorite_animal = 'false'; + this.data.partner.records[2].favorite_animal = 'Undefined'; + + var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot o_enable_linking="1">' + + '<field name="favorite_animal" type="row"/>' + + '</pivot>', + + }); + + assert.strictEqual( + pivot.$('th').slice(0, 2).text(), + [ + '', 'Total', '', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(3).text(), + [ + 'Total', + 'Dog', + 'false', + 'Undefined', + 'Undefined' + + ].join(''), + "The row headers should be as expected" + ); + + unpatchDate(); + pivot.destroy(); + }); + + QUnit.test('group bys added via control panel and expand Header do not stack', async function (assert) { + assert.expect(8); + + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + + assert.strictEqual( + pivot.$('th').slice(0, 2).text(), + [ + '', 'Total', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(3).text(), + [ + 'Total', + ].join(''), + "The row headers should be as expected" + ); + + + // open group by menu and add new groupby + await cpHelpers.toggleGroupByMenu(pivot); + await cpHelpers.toggleAddCustomGroup(pivot); + await cpHelpers.applyGroup(pivot); + + assert.strictEqual( + pivot.$('th').slice(0, 2).text(), + [ + '', 'Total', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(3).text(), + [ + 'Total', + 'Company', + 'individual' + ].join(''), + "The row headers should be as expected" + ); + + // Set a Row groupby + await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed').eq(0)); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first')); + + assert.strictEqual( + pivot.$('th').slice(0, 2).text(), + [ + '', 'Total', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(3).text(), + [ + 'Total', + 'Company', + 'xphone', + 'xpad', + 'individual' + ].join(''), + "The row headers should be as expected" + ); + + // open groupby menu generator and add a new groupby + await cpHelpers.toggleGroupByMenu(pivot); + await cpHelpers.toggleAddCustomGroup(pivot); + await cpHelpers.selectGroup(pivot, 'bar'); + await cpHelpers.applyGroup(pivot); + + assert.strictEqual( + pivot.$('th').slice(0, 2).text(), + [ + '', 'Total', + ].join(''), + "The col headers should be as expected" + ); + assert.strictEqual( + pivot.$('th').slice(3).text(), + [ + 'Total', + 'Company', + 'true', + 'individual', + 'true', + 'Undefined' + ].join(''), + "The row headers should be as expected" + ); + + pivot.destroy(); + }); + + QUnit.test('display only one dropdown menu', async function (assert) { + assert.expect(1); + + var pivot = await createView({ + View: PivotView, + model: 'partner', + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + viewOptions: { + additionalMeasures: ['product_id'], + }, + }); + await testUtils.dom.clickFirst(pivot.$('th.o_pivot_header_cell_closed')); + await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first')); + + // Click on the two dropdown + await testUtils.dom.click(pivot.$('th.o_pivot_header_cell_closed')[0]); + await testUtils.dom.click(pivot.$('th.o_pivot_header_cell_closed')[1]); + + assert.containsOnce(pivot, '.o_pivot_field_menu', 'Only one dropdown should be displayed at a time'); + + pivot.destroy(); + }); + + QUnit.test('Server order is kept by default', async function (assert) { + assert.expect(1); + + let isSecondReadGroup = false; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="customer" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function (route, args) { + if (args.method === 'read_group' && isSecondReadGroup) { + return Promise.resolve([ + { + customer: [2, 'Second'], + foo: 18, + __count: 2, + __domain :[["customer", "=", 2]], + }, + { + customer: [1, 'First'], + foo: 14, + __count: 2, + __domain :[["customer", "=", 1]], + } + ]); + } + var result = this._super.apply(this, arguments); + isSecondReadGroup = true; + return result; + }, + }); + + const values = [ + "32", // Total Value + "18", // Second + "14", // First + ]; + assert.strictEqual(getCurrentValues(pivot), values.join()); + + pivot.destroy(); + }); + + QUnit.test('pivot rendering with boolean field', async function (assert) { + assert.expect(4); + + this.data.partner.fields.bar = {string: "bar", type: "boolean", store: true, searchable: true, group_operator: 'bool_or'}; + this.data.partner.records = [{id: 1, bar: true, date: '2019-12-14'}, {id: 2, bar: false, date: '2019-05-14'}]; + + var pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" type="row" interval="day"/>' + + '<field name="bar" type="col"/>' + + '<field name="bar" string="SLA status Failed" type="measure"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('tbody tr:contains("2019-12-14")').length, 1, 'There should be a first column'); + assert.ok(pivot.$('tbody tr:contains("2019-12-14") [type="checkbox"]').is(':checked'), 'first column contains checkbox and value should be ticked'); + assert.strictEqual(pivot.$('tbody tr:contains("2019-05-14")').length, 1, 'There should be a second column'); + assert.notOk(pivot.$('tbody tr:contains("2019-05-14") [type="checkbox"]').is(':checked'), "second column should have checkbox that is not checked by default"); + + pivot.destroy(); + }); + + QUnit.test('Allow to add behaviour to buttons on pivot', async function (assert) { + assert.expect(2); + + let _testButtons = (ev) => { + if ($(ev.target).hasClass("o_pivot_flip_button")) { + assert.step("o_pivot_flip_button") + } + } + + PivotController.include({ + _addIncludedButtons: async function (ev) { + await this._super(...arguments); + _testButtons(ev); + }, + }); + + const pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="date" type="row" interval="day"/>' + + '<field name="bar" type="col"/>' + + '</pivot>', + }); + await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button')); + assert.verifySteps(["o_pivot_flip_button"]); + _testButtons = () => true; + pivot.destroy(); + }); + + QUnit.test('empty pivot view with action helper', async function (assert) { + assert.expect(4); + + const pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: ` + <pivot> + <field name="product_id" type="measure"/> + <field name="date" interval="month" type="col"/> + </pivot>`, + domain: [['id', '<', 0]], + viewOptions: { + action: { + help: '<p class="abc">click to add a foo</p>' + } + }, + }); + + assert.containsOnce(pivot, '.o_view_nocontent .abc'); + assert.containsNone(pivot, 'table'); + + await pivot.reload({ domain: [] }); + + assert.containsNone(pivot, '.o_view_nocontent .abc'); + assert.containsOnce(pivot, 'table'); + + pivot.destroy(); + }); + + QUnit.test('empty pivot view with sample data', async function (assert) { + assert.expect(7); + + const pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: ` + <pivot sample="1"> + <field name="product_id" type="measure"/> + <field name="date" interval="month" type="col"/> + </pivot>`, + domain: [['id', '<', 0]], + viewOptions: { + action: { + help: '<p class="abc">click to add a foo</p>' + } + }, + }); + + assert.hasClass(pivot.el, 'o_view_sample_data'); + assert.containsOnce(pivot, '.o_view_nocontent .abc'); + assert.containsOnce(pivot, 'table.o_sample_data_disabled'); + + await pivot.reload({ domain: [] }); + + assert.doesNotHaveClass(pivot.el, 'o_view_sample_data'); + assert.containsNone(pivot, '.o_view_nocontent .abc'); + assert.containsOnce(pivot, 'table'); + assert.doesNotHaveClass(pivot.$('table'), 'o_sample_data_disabled'); + + pivot.destroy(); + }); + + QUnit.test('non empty pivot view with sample data', async function (assert) { + assert.expect(7); + + const pivot = await createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: ` + <pivot sample="1"> + <field name="product_id" type="measure"/> + <field name="date" interval="month" type="col"/> + </pivot>`, + viewOptions: { + action: { + help: '<p class="abc">click to add a foo</p>' + } + }, + }); + + assert.doesNotHaveClass(pivot.el, 'o_view_sample_data'); + assert.containsNone(pivot, '.o_view_nocontent .abc'); + assert.containsOnce(pivot, 'table'); + assert.doesNotHaveClass(pivot.$('table'), 'o_sample_data_disabled'); + + await pivot.reload({ domain: [['id', '<', 0]] }); + + assert.doesNotHaveClass(pivot.el, 'o_view_sample_data'); + assert.containsOnce(pivot, '.o_view_nocontent .abc'); + assert.containsNone(pivot, 'table'); + + pivot.destroy(); + }); +}); +}); diff --git a/addons/web/static/tests/views/qweb_tests.js b/addons/web/static/tests/views/qweb_tests.js new file mode 100644 index 00000000..19acf9b0 --- /dev/null +++ b/addons/web/static/tests/views/qweb_tests.js @@ -0,0 +1,72 @@ +odoo.define('web.qweb_view_tests', function (require) { +"use strict"; + +var utils = require('web.test_utils'); + +QUnit.module("Views", { + +}, function () { + QUnit.module("QWeb"); + QUnit.test("basic", async function (assert) { + assert.expect(14); + var am = await utils.createActionManager({ + data: { + test: { + fields: {}, + records: [], + } + }, + archs: { + 'test,5,qweb': '<div id="xxx"><t t-esc="ok"/></div>', + 'test,false,search': '<search/>' + }, + mockRPC: function (route, args) { + if (/^\/web\/dataset\/call_kw/.test(route)) { + switch (_.str.sprintf('%(model)s.%(method)s', args)) { + case 'test.qweb_render_view': + assert.step('fetch'); + assert.equal(args.kwargs.view_id, 5); + return Promise.resolve( + '<div>foo' + + '<div data-model="test" data-method="wheee" data-id="42" data-other="5">' + + '<a type="toggle" class="fa fa-caret-right">Unfold</a>' + + '</div>' + + '</div>' + ); + case 'test.wheee': + assert.step('unfold'); + assert.deepEqual(args.args, [42]); + assert.deepEqual(args.kwargs, {other: 5}); + return Promise.resolve('<div id="sub">ok</div>'); + } + } + return this._super.apply(this, arguments); + } + }); + try { + var resolved = false; + am.doAction({ + type: 'ir.actions.act_window', + views: [[false, 'qweb']], + res_model: 'test', + }).then(function () { resolved = true; }); + assert.ok(!resolved, "Action cannot be resolved synchronously"); + + await utils.nextTick(); + assert.ok(resolved, "Action is resolved asynchronously"); + + var $content = am.$('.o_content'); + assert.ok(/^\s*foo/.test($content.text())); + await utils.dom.click($content.find('[type=toggle]')); + assert.equal($content.find('div#sub').text(), 'ok', 'should have unfolded the sub-item'); + await utils.dom.click($content.find('[type=toggle]')); + assert.equal($content.find('div#sub').length, 0, "should have removed the sub-item"); + await utils.dom.click($content.find('[type=toggle]')); + + assert.verifySteps(['fetch', 'unfold', 'unfold']); + } finally { + am.destroy(); + } + }); +}); +}); diff --git a/addons/web/static/tests/views/sample_server_tests.js b/addons/web/static/tests/views/sample_server_tests.js new file mode 100644 index 00000000..35e33e63 --- /dev/null +++ b/addons/web/static/tests/views/sample_server_tests.js @@ -0,0 +1,486 @@ +odoo.define('web.sample_server_tests', function (require) { + "use strict"; + + const SampleServer = require('web.SampleServer'); + const session = require('web.session'); + const { mock } = require('web.test_utils'); + + const { + MAIN_RECORDSET_SIZE, SEARCH_READ_LIMIT, // Limits + SAMPLE_COUNTRIES, SAMPLE_PEOPLE, SAMPLE_TEXTS, // Text values + MAX_COLOR_INT, MAX_FLOAT, MAX_INTEGER, MAX_MONETARY, // Number values + SUB_RECORDSET_SIZE, // Records sise + } = SampleServer; + + /** + * Transforms random results into deterministic ones. + */ + class DeterministicSampleServer extends SampleServer { + constructor() { + super(...arguments); + this.arrayElCpt = 0; + this.boolCpt = 0; + this.subRecordIdCpt = 0; + } + _getRandomArrayEl(array) { + return array[this.arrayElCpt++ % array.length]; + } + _getRandomBool() { + return Boolean(this.boolCpt++ % 2); + } + _getRandomSubRecordId() { + return (this.subRecordIdCpt++ % SUB_RECORDSET_SIZE) + 1; + } + } + + QUnit.module("Sample Server", { + beforeEach() { + this.fields = { + 'res.users': { + display_name: { string: "Name", type: 'char' }, + name: { string: "Reference", type: 'char' }, + email: { string: "Email", type: 'char' }, + phone_number: { string: "Phone number", type: 'char' }, + brol_machin_url_truc: { string: "URL", type: 'char' }, + urlemailphone: { string: "Whatever", type: 'char' }, + active: { string: "Active", type: 'boolean' }, + is_alive: { string: "Is alive", type: 'boolean' }, + description: { string: "Description", type: 'text' }, + birthday: { string: "Birthday", type: 'date' }, + arrival_date: { string: "Date of arrival", type: 'datetime' }, + height: { string: "Height", type: 'float' }, + color: { string: "Color", type: 'integer' }, + age: { string: "Age", type: 'integer' }, + salary: { string: "Salary", type: 'monetary' }, + currency: { string: "Currency", type: 'many2one', relation: 'res.currency' }, + manager_id: { string: "Manager", type: 'many2one', relation: 'res.users' }, + cover_image_id: { string: "Cover Image", type: 'many2one', relation: 'ir.attachment' }, + managed_ids: { string: "Managing", type: 'one2many', relation: 'res.users' }, + tag_ids: { string: "Tags", type: 'many2many', relation: 'tag' }, + type: { string: "Type", type: 'selection', selection: [ + ['client', "Client"], ['partner', "Partner"], ['employee', "Employee"] + ] }, + }, + 'res.country': { + display_name: { string: "Name", type: 'char' }, + }, + 'hobbit': { + display_name: { string: "Name", type: 'char' }, + profession: { string: "Profession", type: 'selection', selection: [ + ['gardener', "Gardener"], ['brewer', "Brewer"], ['adventurer', "Adventurer"] + ] }, + age: { string: "Age", type: 'integer' }, + }, + 'ir.attachment': { + display_name: { string: "Name", type: 'char' }, + } + }; + }, + }, function () { + + QUnit.module("Basic behaviour"); + + QUnit.test("Sample data: people type + all field names", async function (assert) { + assert.expect(26); + + mock.patch(session, { + company_currency_id: 4, + }); + + const allFieldNames = Object.keys(this.fields['res.users']); + const server = new DeterministicSampleServer('res.users', this.fields['res.users']); + const { records } = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'res.users', + fields: allFieldNames, + }); + const rec = records[0]; + + function assertFormat(fieldName, regex) { + if (regex instanceof RegExp) { + assert.ok( + regex.test(rec[fieldName].toString()), + `Field "${fieldName}" has the correct format` + ); + } else { + assert.strictEqual( + typeof rec[fieldName], regex, + `Field "${fieldName}" is of type ${regex}` + ); + } + } + function assertBetween(fieldName, min, max, isFloat = false) { + const val = rec[fieldName]; + assert.ok( + min <= val && val < max && (isFloat || parseInt(val, 10) === val), + `Field "${fieldName}" is between ${min} and ${max} ${!isFloat ? 'and is an integer ' : ''}: ${val}` + ); + } + + // Basic fields + assert.ok(SAMPLE_PEOPLE.includes(rec.display_name)); + assert.ok(SAMPLE_PEOPLE.includes(rec.name)); + assert.strictEqual(rec.email, + `${rec.display_name.replace(/ /, ".").toLowerCase()}@sample.demo` + ); + assertFormat('phone_number', /\+1 555 754 000\d/); + assertFormat('brol_machin_url_truc', /http:\/\/sample\d\.com/); + assert.strictEqual(rec.urlemailphone, false); + assert.strictEqual(rec.active, true); + assertFormat('is_alive', 'boolean'); + assert.ok(SAMPLE_TEXTS.includes(rec.description)); + assertFormat('birthday', /\d{4}-\d{2}-\d{2}/); + assertFormat('arrival_date', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + assertBetween('height', 0, MAX_FLOAT, true); + assertBetween('color', 0, MAX_COLOR_INT); + assertBetween('age', 0, MAX_INTEGER); + assertBetween('salary', 0, MAX_MONETARY); + + // check float field have 2 decimal rounding + assert.strictEqual(rec.height, parseFloat(parseFloat(rec.height).toFixed(2))); + + const selectionValues = this.fields['res.users'].type.selection.map( + (sel) => sel[0] + ); + assert.ok(selectionValues.includes(rec.type)); + + // Relational fields + assert.strictEqual(rec.currency[0], 4); + // Currently we expect the currency name to be a latin string, which + // is not important; in most case we only need the ID. The following + // assertion can be removed if needed. + assert.ok(SAMPLE_TEXTS.includes(rec.currency[1])); + + assert.strictEqual(typeof rec.manager_id[0], 'number'); + assert.ok(SAMPLE_PEOPLE.includes(rec.manager_id[1])); + + assert.strictEqual(rec.cover_image_id, false); + + assert.strictEqual(rec.managed_ids.length, 2); + assert.ok(rec.managed_ids.every( + (id) => typeof id === 'number') + ); + + assert.strictEqual(rec.tag_ids.length, 2); + assert.ok(rec.tag_ids.every( + (id) => typeof id === 'number') + ); + + mock.unpatch(session); + }); + + QUnit.test("Sample data: country type", async function (assert) { + assert.expect(1); + + const server = new DeterministicSampleServer('res.country', this.fields['res.country']); + const { records } = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'res.country', + fields: ['display_name'], + }); + + assert.ok(SAMPLE_COUNTRIES.includes(records[0].display_name)); + }); + + QUnit.test("Sample data: any type", async function (assert) { + assert.expect(1); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const { records } = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'hobbit', + fields: ['display_name'], + }); + + assert.ok(SAMPLE_TEXTS.includes(records[0].display_name)); + }); + + QUnit.module("RPC calls"); + + QUnit.test("Send 'search_read' RPC: valid field names", async function (assert) { + assert.expect(3); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'hobbit', + fields: ['display_name'], + }); + + assert.deepEqual( + Object.keys(result.records[0]), + ['id', 'display_name'] + ); + assert.strictEqual(result.length, SEARCH_READ_LIMIT); + assert.ok(/\w+/.test(result.records[0].display_name), + "Display name has been mocked" + ); + }); + + QUnit.test("Send 'search_read' RPC: invalid field names", async function (assert) { + assert.expect(3); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'hobbit', + fields: ['name'], + }); + + assert.deepEqual( + Object.keys(result.records[0]), + ['id', 'name'] + ); + assert.strictEqual(result.length, SEARCH_READ_LIMIT); + assert.strictEqual(result.records[0].name, false, + `Field "name" doesn't exist => returns false` + ); + }); + + QUnit.test("Send 'web_read_group' RPC: no group", async function (assert) { + assert.expect(1); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + server.setExistingGroups([]); + + const result = await server.mockRpc({ + method: 'web_read_group', + model: 'hobbit', + groupBy: ['profession'], + }); + + assert.deepEqual(result, { groups: [], length: 0 }); + }); + + QUnit.test("Send 'web_read_group' RPC: 2 groups", async function (assert) { + assert.expect(5); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + const existingGroups = [ + { profession: 'gardener', profession_count: 0 }, + { profession: 'adventurer', profession_count: 0 }, + ]; + server.setExistingGroups(existingGroups); + + const result = await server.mockRpc({ + method: 'web_read_group', + model: 'hobbit', + groupBy: ['profession'], + fields: [], + }); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result.groups.length, 2); + + assert.deepEqual( + result.groups.map((g) => g.profession), + ["gardener", "adventurer"] + ); + + assert.strictEqual( + result.groups.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE + ); + assert.ok( + result.groups.every((g) => g.profession_count === g.__data.length) + ); + }); + + QUnit.test("Send 'web_read_group' RPC: all groups", async function (assert) { + assert.expect(5); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + const existingGroups = [ + { profession: 'gardener', profession_count: 0 }, + { profession: 'brewer', profession_count: 0 }, + { profession: 'adventurer', profession_count: 0 }, + ]; + server.setExistingGroups(existingGroups); + + const result = await server.mockRpc({ + method: 'web_read_group', + model: 'hobbit', + groupBy: ['profession'], + fields: [], + }); + + assert.strictEqual(result.length, 3); + assert.strictEqual(result.groups.length, 3); + + assert.deepEqual( + result.groups.map((g) => g.profession), + ["gardener", "brewer", "adventurer"] + ); + + assert.strictEqual( + result.groups.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE + ); + assert.ok( + result.groups.every((g) => g.profession_count === g.__data.length) + ); + }); + + QUnit.test("Send 'read_group' RPC: no group", async function (assert) { + assert.expect(1); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: [], + }); + + assert.deepEqual(result, [{ + __count: MAIN_RECORDSET_SIZE, + __domain: [], + }]); + }); + + QUnit.test("Send 'read_group' RPC: groupBy", async function (assert) { + assert.expect(3); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: ['profession'], + }); + + assert.strictEqual(result.length, 3); + assert.deepEqual( + result.map((g) => g.profession), + ["adventurer", "brewer", "gardener"] + ); + assert.strictEqual( + result.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE, + ); + }); + + QUnit.test("Send 'read_group' RPC: groupBy and field", async function (assert) { + assert.expect(4); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: ['age'], + groupBy: ['profession'], + }); + + assert.strictEqual(result.length, 3); + assert.deepEqual( + result.map((g) => g.profession), + ["adventurer", "brewer", "gardener"] + ); + assert.strictEqual( + result.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE, + ); + assert.strictEqual( + result.reduce((acc, g) => acc + g.age, 0), + server.data.hobbit.records.reduce((acc, g) => acc + g.age, 0) + ); + }); + + QUnit.test("Send 'read_group' RPC: multiple groupBys and lazy", async function (assert) { + assert.expect(2); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: ['profession', 'age'], + }); + + assert.ok('profession' in result[0]); + assert.notOk('age' in result[0]); + }); + + QUnit.test("Send 'read_group' RPC: multiple groupBys and not lazy", async function (assert) { + assert.expect(2); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: ['profession', 'age'], + lazy: false, + }); + + assert.ok('profession' in result[0]); + assert.ok('age' in result[0]); + }); + + QUnit.test("Send 'read' RPC: no id", async function (assert) { + assert.expect(1); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read', + model: 'hobbit', + args: [ + [], ['display_name'] + ], + }); + + assert.deepEqual(result, []); + }); + + QUnit.test("Send 'read' RPC: one id", async function (assert) { + assert.expect(3); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read', + model: 'hobbit', + args: [ + [1], ['display_name'] + ], + }); + + assert.strictEqual(result.length, 1); + assert.ok( + /\w+/.test(result[0].display_name), + "Display name has been mocked" + ); + assert.strictEqual(result[0].id, 1); + }); + + QUnit.test("Send 'read' RPC: more than all available ids", async function (assert) { + assert.expect(1); + + const server = new DeterministicSampleServer('hobbit', this.fields.hobbit); + + const amount = MAIN_RECORDSET_SIZE + 3; + const ids = new Array(amount).fill().map((_, i) => i + 1); + const result = await server.mockRpc({ + method: 'read', + model: 'hobbit', + args: [ + ids, ['display_name'] + ], + }); + + assert.strictEqual(result.length, MAIN_RECORDSET_SIZE); + }); + + // To be implemented if needed + // QUnit.test("Send 'read_progress_bar' RPC", async function (assert) { ... }); + }); +}); diff --git a/addons/web/static/tests/views/search_panel_tests.js b/addons/web/static/tests/views/search_panel_tests.js new file mode 100644 index 00000000..cf2cd4b4 --- /dev/null +++ b/addons/web/static/tests/views/search_panel_tests.js @@ -0,0 +1,4173 @@ +odoo.define("web/static/tests/views/search_panel_tests.js", function (require) { +"use strict"; + +const FormView = require('web.FormView'); +const KanbanView = require('web.KanbanView'); +const ListView = require('web.ListView'); +const testUtils = require('web.test_utils'); +const SearchPanel = require("web/static/src/js/views/search_panel.js"); + +const cpHelpers = testUtils.controlPanel; +const createActionManager = testUtils.createActionManager; +const createView = testUtils.createView; + +/** + * Return the list of counters displayed in the search panel (if any). + * @param {Widget} view, view controller + * @returns {number[]} + */ +function getCounters(view) { + return [...view.el.querySelectorAll('.o_search_panel_counter')].map( + counter => Number(counter.innerText.trim()) + ); +} + +/** + * Fold/unfold the category value (with children) + * @param {Widget} widget + * @param {string} text + * @returns {Promise} + */ +function toggleFold(widget, text) { + const headers = [...widget.el.querySelectorAll(".o_search_panel_category_value header")]; + const target = headers.find( + (header) => header.innerText.trim().startsWith(text) + ); + return testUtils.dom.click(target); +} + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: 'char'}, + bar: {string: "Bar", type: 'boolean'}, + int_field: {string: "Int Field", type: 'integer', group_operator: 'sum'}, + company_id: {string: "company", type: 'many2one', relation: 'company'}, + company_ids: { string: "Companies", type: 'many2many', relation: 'company' }, + category_id: { string: "category", type: 'many2one', relation: 'category' }, + state: { string: "State", type: 'selection', selection: [['abc', "ABC"], ['def', "DEF"], ['ghi', "GHI"]]}, + }, + records: [ + {id: 1, bar: true, foo: "yop", int_field: 1, company_ids: [3], company_id: 3, state: 'abc', category_id: 6}, + {id: 2, bar: true, foo: "blip", int_field: 2, company_ids: [3], company_id: 5, state: 'def', category_id: 7}, + {id: 3, bar: true, foo: "gnap", int_field: 4, company_ids: [], company_id: 3, state: 'ghi', category_id: 7}, + {id: 4, bar: false, foo: "blip", int_field: 8, company_ids: [5], company_id: 5, state: 'ghi', category_id: 7}, + ] + }, + company: { + fields: { + name: {string: "Display Name", type: 'char'}, + parent_id: {string: 'Parent company', type: 'many2one', relation: 'company'}, + category_id: {string: 'Category', type: 'many2one', relation: 'category'}, + }, + records: [ + {id: 3, name: "asustek", category_id: 6}, + {id: 5, name: "agrolait", category_id: 7}, + ], + }, + category: { + fields: { + name: {string: "Category Name", type: 'char'}, + }, + records: [ + {id: 6, name: "gold"}, + {id: 7, name: "silver"}, + ] + }, + }; + + this.actions = [{ + id: 1, + name: 'Partners', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'kanban'], [false, 'list'], [false, 'pivot'], [false, 'form']], + }, { + id: 2, + name: 'Partners', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'form']], + }]; + + this.archs = { + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,kanban': + `<kanban> + <templates> + <div t-name="kanban-box" class="oe_kanban_global_click"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + 'partner,false,form': + `<form> + <button name="1" type="action" string="multi view"/> + <field name="foo"/> + </form>`, + 'partner,false,pivot': '<pivot><field name="int_field" type="measure"/></pivot>', + 'partner,false,search': + `<search> + <searchpanel> + <field name="company_id" enable_counters="1" expand="1"/> + <field name="category_id" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }; + }, +}, function () { + + QUnit.module('SearchPanel'); + + QUnit.test('basic rendering', async function (assert) { + assert.expect(16); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method + ' on ' + args.model); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + <field name="category_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + assert.containsOnce(kanban, '.o_content.o_controller_with_searchpanel > .o_search_panel'); + assert.containsOnce(kanban, '.o_content.o_controller_with_searchpanel > .o_kanban_view'); + + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4); + + assert.containsN(kanban, '.o_search_panel_section', 2); + + var $firstSection = kanban.$('.o_search_panel_section:first'); + assert.hasClass($firstSection.find('.o_search_panel_section_header i'), 'fa-folder'); + assert.containsOnce($firstSection, '.o_search_panel_section_header:contains(company)'); + assert.containsN($firstSection, '.o_search_panel_category_value', 3); + assert.containsOnce($firstSection, '.o_search_panel_category_value:first .active'); + assert.strictEqual($firstSection.find('.o_search_panel_category_value').text().replace(/\s/g, ''), + 'Allasustek2agrolait2'); + + var $secondSection = kanban.$('.o_search_panel_section:nth(1)'); + assert.hasClass($secondSection.find('.o_search_panel_section_header i'), 'fa-filter'); + assert.containsOnce($secondSection, '.o_search_panel_section_header:contains(category)'); + assert.containsN($secondSection, '.o_search_panel_filter_value', 2); + assert.strictEqual($secondSection.find('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'gold1silver3'); + + assert.verifySteps([ + 'search_panel_select_range on partner', + 'search_panel_select_multi_range on partner', + ]); + + kanban.destroy(); + }); + + QUnit.test('sections with custom icon and color', async function (assert) { + assert.expect(4); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" icon="fa-car" color="blue" enable_counters="1"/> + <field name="state" select="multi" icon="fa-star" color="#000" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + assert.hasClass(kanban.$('.o_search_panel_section_header:first i'), 'fa-car'); + assert.hasAttrValue(kanban.$('.o_search_panel_section_header:first i'), 'style="{color: blue}"'); + assert.hasClass(kanban.$('.o_search_panel_section_header:nth(1) i'), 'fa-star'); + assert.hasAttrValue(kanban.$('.o_search_panel_section_header:nth(1) i'), 'style="{color: #000}"'); + + kanban.destroy(); + }); + + QUnit.test('sections with attr invisible="1" are ignored', async function (assert) { + // 'groups' attributes are converted server-side into invisible="1" when the user doesn't + // belong to the given group + assert.expect(3); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: `<kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + <field name="state" select="multi" invisible="1" enable_counters="1"/> + </searchpanel> + </search>`, + }, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method || route); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsOnce(kanban, '.o_search_panel_section'); + + assert.verifySteps([ + 'search_panel_select_range', + ]); + + kanban.destroy(); + }); + + QUnit.test('categories and filters order is kept', async function (assert) { + assert.expect(4); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + <field name="category_id" select="multi" enable_counters="1"/> + <field name="state" enable_counters="1"/> + </searchpanel> + </search>`, + } + }); + + assert.containsN(kanban, '.o_search_panel_section', 3); + assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(0)').text().trim(), + 'company'); + assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(1)').text().trim(), + 'category'); + assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(2)').text().trim(), + 'State'); + + kanban.destroy(); + }); + + QUnit.test('specify active category value in context and manually change category', async function (assert) { + assert.expect(5); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + <field name="state" enable_counters="1"/> + </searchpanel> + </search>`, + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + context: { + searchpanel_default_company_id: false, + searchpanel_default_state: 'ghi', + }, + }); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value header.active label')].map( + el => el.innerText + ), + ['All', 'GHI'] + ); + + // select 'ABC' in the category 'state' + await testUtils.dom.click(kanban.el.querySelectorAll('.o_search_panel_category_value header')[4]); + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value header.active label')].map( + el => el.innerText + ), + ['All', 'ABC'] + ); + + assert.verifySteps([ + '[["state","=","ghi"]]', + '[["state","=","abc"]]' + ]); + kanban.destroy(); + }); + + QUnit.test('use category (on many2one) to refine search', async function (assert) { + assert.expect(14); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + domain: [['bar', '=', true]], + }); + + // select 'asustek' + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + // select 'agrolait' + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // select 'All' + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3); + + assert.verifySteps([ + '[["bar","=",true]]', + '["&",["bar","=",true],["company_id","child_of",3]]', + '["&",["bar","=",true],["company_id","child_of",5]]', + '[["bar","=",true]]', + ]); + + kanban.destroy(); + }); + + QUnit.test('use category (on selection) to refine search', async function (assert) { + assert.expect(14); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + // select 'abc' + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // select 'ghi' + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(3) header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + // select 'All' again + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + assert.verifySteps([ + '[]', + '[["state","=","abc"]]', + '[["state","=","ghi"]]', + '[]', + ]); + + kanban.destroy(); + }); + + QUnit.test('category has been archived', async function (assert) { + assert.expect(2); + + this.data.company.fields.active = {type: 'boolean', string: 'Archived'}; + this.data.company.records = [ + { + name: 'Company 5', + id: 5, + active: true, + }, { + name: 'child of 5 archived', + parent_id: 5, + id: 666, + active: false, + }, { + name: 'child of 666', + parent_id: 666, + id: 777, + active: true, + } + ]; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + mockRPC: async function (route, args) { + if (route === '/web/dataset/call_kw/partner/search_panel_select_range') { + var results = await this._super.apply(this, arguments); + results.values = results.values.filter(rec => rec.active !== false); + return Promise.resolve(results); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_search_panel_category_value', 2, + 'The number of categories should be 2: All and Company 5'); + + assert.containsNone(kanban, '.o_toggle_fold > i', + 'None of the categories should have children'); + + kanban.destroy(); + }); + + QUnit.test('use two categories to refine search', async function (assert) { + assert.expect(14); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + <field name="state" enable_counters="1"/> + </searchpanel> + </search>`, + }, + domain: [['bar', '=', true]], + }); + + assert.containsN(kanban, '.o_search_panel_section', 2); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3); + + // select 'asustek' + await testUtils.dom.click( + [ + ...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title') + ] + .filter(el => el.innerText === 'asustek') + ); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + // select 'abc' + await testUtils.dom.click( + [ + ...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title') + ] + .filter(el => el.innerText === 'ABC') + ); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // select 'ghi' + await testUtils.dom.click( + [ + ...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title') + ] + .filter(el => el.innerText === 'GHI') + ); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // select 'All' in first category (company_id) + await testUtils.dom.click(kanban.$('.o_search_panel_section:first .o_search_panel_category_value:first header')); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // select 'All' in second category (state) + await testUtils.dom.click(kanban.$('.o_search_panel_section:nth(1) .o_search_panel_category_value:first header')); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3); + + assert.verifySteps([ + '[["bar","=",true]]', + '["&",["bar","=",true],["company_id","child_of",3]]', + '["&",["bar","=",true],"&",["company_id","child_of",3],["state","=","abc"]]', + '["&",["bar","=",true],"&",["company_id","child_of",3],["state","=","ghi"]]', + '["&",["bar","=",true],["state","=","ghi"]]', + '[["bar","=",true]]', + ]); + + kanban.destroy(); + }); + + QUnit.test('category with parent_field', async function (assert) { + assert.expect(33); + + this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5}); + this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5}); + this.data.partner.records[1].company_id = 40; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + }); + + // 'All' is selected by default + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + assert.containsN(kanban, '.o_search_panel_category_value', 3); + assert.containsOnce(kanban, '.o_search_panel_category_value .o_toggle_fold > i'); + + // unfold parent category and select 'All' again + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header')); + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first > header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + assert.containsN(kanban, '.o_search_panel_category_value', 5); + assert.containsN(kanban, '.o_search_panel_category_value .o_search_panel_category_value', 2); + + // click on first child company + await testUtils.dom.click(kanban.$('.o_search_panel_category_value .o_search_panel_category_value:first header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value .o_search_panel_category_value:first .active'); + assert.containsOnce(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + + // click on parent company + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsOnce(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + + // fold parent company by clicking on it + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // parent company should be folded + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsN(kanban, '.o_search_panel_category_value', 3); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // fold category with children + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header')); + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsN(kanban, '.o_search_panel_category_value', 3); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + assert.verifySteps([ + '[]', + '[["company_id","child_of",5]]', + '[]', + '[["company_id","child_of",40]]', + '[["company_id","child_of",5]]', + ]); + + kanban.destroy(); + }); + + QUnit.test('category with no parent_field', async function (assert) { + assert.expect(10); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="category_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + // 'All' is selected by default + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + assert.containsN(kanban, '.o_search_panel_category_value', 3); + + // click on 'gold' category + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active'); + assert.containsOnce(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + + assert.verifySteps([ + '[]', + '[["category_id","=",6]]', // must use '=' operator (instead of 'child_of') + ]); + + kanban.destroy(); + }); + + QUnit.test('can (un)fold parent category values', async function (assert) { + assert.expect(7); + + this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5}); + this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5}); + this.data.partner.records[1].company_id = 40; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + }); + + assert.strictEqual(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i').length, 1, + "'agrolait' should be displayed as a parent category value"); + assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-right', + "'agrolait' should be folded"); + assert.containsN(kanban, '.o_search_panel_category_value', 3); + + // unfold agrolait + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i')); + assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-down', + "'agrolait' should be open"); + assert.containsN(kanban, '.o_search_panel_category_value', 5); + + // fold agrolait + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i')); + assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-right', + "'agrolait' should be folded"); + assert.containsN(kanban, '.o_search_panel_category_value', 3); + + kanban.destroy(); + }); + + QUnit.test('fold status is kept at reload', async function (assert) { + assert.expect(4); + + this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5}); + this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5}); + this.data.partner.records[1].company_id = 40; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + }); + + // unfold agrolait + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) > header')); + assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-down', + "'agrolait' should be open"); + assert.containsN(kanban, '.o_search_panel_category_value', 5); + + await kanban.reload({}); + + assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-down', + "'agrolait' should be open"); + assert.containsN(kanban, '.o_search_panel_category_value', 5); + + kanban.destroy(); + }); + + QUnit.test('concurrency: delayed search_reads', async function (assert) { + assert.expect(19); + + var def; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + return Promise.resolve(def).then(_.constant(result)); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + domain: [['bar', '=', true]], + }); + + // 'All' should be selected by default + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3); + + // select 'asustek' (delay the reload) + def = testUtils.makeTestPromise(); + var asustekDef = def; + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + // 'asustek' should be selected, but there should still be 3 records + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3); + + // select 'agrolait' (delay the reload) + def = testUtils.makeTestPromise(); + var agrolaitDef = def; + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header')); + + // 'agrolait' should be selected, but there should still be 3 records + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3); + + // unlock asustek search (should be ignored, so there should still be 3 records) + asustekDef.resolve(); + await testUtils.nextTick(); + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3); + + // unlock agrolait search, there should now be 1 record + agrolaitDef.resolve(); + await testUtils.nextTick(); + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + assert.verifySteps([ + '[["bar","=",true]]', + '["&",["bar","=",true],["company_id","child_of",3]]', + '["&",["bar","=",true],["company_id","child_of",5]]', + ]); + + kanban.destroy(); + }); + + QUnit.test("concurrency: single category", async function (assert) { + assert.expect(12); + + let prom = testUtils.makeTestPromise(); + const kanbanPromise = createView({ + arch: ` + <kanban> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + archs: { + "partner,false,search": ` + <search> + <filter name="Filter" domain="[('id', '=', 1)]"/> + <searchpanel> + <field name="company_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + context: { + searchpanel_default_company_id: [5], + }, + data: this.data, + async mockRPC(route, args) { + const _super = this._super.bind(this); + if (route !== "/web/dataset/search_read") { + await prom; + } + assert.step(args.method || route); + return _super(...arguments); + }, + model: 'partner', + View: KanbanView, + }); + + // Case 1: search panel is awaited to build the query with search defaults + await testUtils.nextTick(); + assert.verifySteps([]); + + prom.resolve(); + const kanban = await kanbanPromise; + + assert.verifySteps([ + "search_panel_select_range", + "/web/dataset/search_read", + ]); + + // Case 2: search domain changed so we wait for the search panel once again + prom = testUtils.makeTestPromise(); + + await testUtils.controlPanel.toggleFilterMenu(kanban); + await testUtils.controlPanel.toggleMenuItem(kanban, 0); + + assert.verifySteps([]); + + prom.resolve(); + await testUtils.nextTick(); + + assert.verifySteps([ + "search_panel_select_range", + "/web/dataset/search_read", + ]); + + // Case 3: search domain is the same and default values do not matter anymore + prom = testUtils.makeTestPromise(); + + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + // The search read is executed right away in this case + assert.verifySteps(["/web/dataset/search_read"]); + + prom.resolve(); + await testUtils.nextTick(); + + assert.verifySteps(["search_panel_select_range"]); + + kanban.destroy(); + }); + + QUnit.test("concurrency: category and filter", async function (assert) { + assert.expect(5); + + let prom = testUtils.makeTestPromise(); + const kanbanPromise = createView({ + arch: ` + <kanban> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + archs: { + "partner,false,search": ` + <search> + <searchpanel> + <field name="category_id" enable_counters="1"/> + <field name="company_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + async mockRPC(route, args) { + const _super = this._super.bind(this); + if (route !== "/web/dataset/search_read") { + await prom; + } + assert.step(args.method || route); + return _super(...arguments); + }, + model: 'partner', + View: KanbanView, + }); + + await testUtils.nextTick(); + assert.verifySteps(["/web/dataset/search_read"]); + + prom.resolve(); + const kanban = await kanbanPromise; + + assert.verifySteps([ + "search_panel_select_range", + "search_panel_select_multi_range", + ]); + + kanban.destroy(); + }); + + QUnit.test("concurrency: category and filter with a domain", async function (assert) { + assert.expect(5); + + let prom = testUtils.makeTestPromise(); + const kanbanPromise = createView({ + arch: ` + <kanban> + <templates> + <div t-name="kanban-box"> + <field name="foo"/> + </div> + </templates> + </kanban>`, + archs: { + "partner,false,search": ` + <search> + <searchpanel> + <field name="category_id" enable_counters="1"/> + <field name="company_id" select="multi" domain="[['category_id', '=', category_id]]" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + async mockRPC(route, args) { + const _super = this._super.bind(this); + if (route !== "/web/dataset/search_read") { + await prom; + } + assert.step(args.method || route); + return _super(...arguments); + }, + model: 'partner', + View: KanbanView, + }); + + await testUtils.nextTick(); + assert.verifySteps([]); + + prom.resolve(); + const kanban = await kanbanPromise; + + assert.verifySteps([ + "search_panel_select_range", + "search_panel_select_multi_range", + "/web/dataset/search_read", + ]); + + kanban.destroy(); + }); + + QUnit.test('concurrency: misordered get_filters', async function (assert) { + assert.expect(15); + + var def; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'search_panel_select_multi_range') { + return Promise.resolve(def).then(_.constant(result)); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" enable_counters="1"/> + <field name="company_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + // 'All' should be selected by default + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + // select 'abc' (delay the reload) + def = testUtils.makeTestPromise(); + var abcDef = def; + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + // 'All' should still be selected, and there should still be 4 records + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // select 'ghi' (delay the reload) + def = testUtils.makeTestPromise(); + var ghiDef = def; + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(3) header')); + + // 'All' should still be selected, and there should still be 4 records + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + // unlock ghi search + ghiDef.resolve(); + await testUtils.nextTick(); + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + // unlock abc search (should be ignored) + abcDef.resolve(); + await testUtils.nextTick(); + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + kanban.destroy(); + }); + + QUnit.test('concurrency: delayed get_filter', async function (assert) { + assert.expect(3); + + var def; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'search_panel_select_multi_range') { + return Promise.resolve(def).then(_.constant(result)); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <filter name="Filter" domain="[('id', '=', 1)]"/> + <searchpanel> + <field name="company_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4); + + // trigger a reload and delay the get_filter + def = testUtils.makeTestPromise(); + await cpHelpers.toggleFilterMenu(kanban); + await cpHelpers.toggleMenuItem(kanban, 0); + await testUtils.nextTick(); + + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4); + + def.resolve(); + await testUtils.nextTick(); + + assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)'); + + kanban.destroy(); + }); + + QUnit.test('use filter (on many2one) to refine search', async function (assert) { + assert.expect(32); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'search_panel_select_multi_range') { + // the following keys should have same value for all calls to this route + var keys = ['field_name', 'group_by', 'comodel_domain', 'search_domain', 'category_domain']; + assert.deepEqual(_.pick(args.kwargs, keys), { + group_by: false, + comodel_domain: [], + search_domain: [['bar', '=', true]], + category_domain: [], + }); + // the filter_domain depends on the filter selection + assert.step(JSON.stringify(args.kwargs.filter_domain)); + } + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + domain: [['bar', '=', true]], + }); + + assert.containsN(kanban, '.o_search_panel_filter_value', 2); + assert.containsNone(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolait1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3); + + // check 'asustek' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input')); + + assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolait1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2); + + // check 'agrolait' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input')); + + assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolait1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3); + + // uncheck 'asustek' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input')); + + assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolait1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1); + + // uncheck 'agrolait' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input')); + + assert.containsNone(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolait1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3); + + assert.verifySteps([ + // nothing checked + '[]', + '[["bar","=",true]]', + // 'asustek' checked + '[]', + '["&",["bar","=",true],["company_id","in",[3]]]', + // 'asustek' and 'agrolait' checked + '[]', + '["&",["bar","=",true],["company_id","in",[3,5]]]', + // 'agrolait' checked + '[]', + '["&",["bar","=",true],["company_id","in",[5]]]', + // nothing checked + '[]', + '[["bar","=",true]]', + ]); + + kanban.destroy(); + }); + + QUnit.test('use filter (on selection) to refine search', async function (assert) { + assert.expect(32); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'search_panel_select_multi_range') { + // the following keys should have same value for all calls to this route + var keys = ['group_by', 'comodel_domain', 'search_domain', 'category_domain']; + assert.deepEqual(_.pick(args.kwargs, keys), { + group_by: false, + comodel_domain: [], + search_domain: [['bar', '=', true]], + category_domain: [], + }); + // the filter_domain depends on the filter selection + assert.step(JSON.stringify(args.kwargs.filter_domain)); + } + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + domain: [['bar', '=', true]], + }); + + assert.containsN(kanban, '.o_search_panel_filter_value', 3); + assert.containsNone(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'ABC1DEF1GHI1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3); + + // check 'abc' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input')); + + assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'ABC1DEF1GHI1'); + assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1); + + // check 'def' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input')); + + assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'ABC1DEF1GHI1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2); + + // uncheck 'abc' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input')); + + assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'ABC1DEF1GHI1'); + assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)'); + + // uncheck 'def' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input')); + + assert.containsNone(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'ABC1DEF1GHI1'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3); + + assert.verifySteps([ + // nothing checked + '[]', + '[["bar","=",true]]', + // 'asustek' checked + '[]', + '["&",["bar","=",true],["state","in",["abc"]]]', + // 'asustek' and 'agrolait' checked + '[]', + '["&",["bar","=",true],["state","in",["abc","def"]]]', + // 'agrolait' checked + '[]', + '["&",["bar","=",true],["state","in",["def"]]]', + // nothing checked + '[]', + '[["bar","=",true]]', + ]); + + kanban.destroy(); + }); + + QUnit.test("only reload categories and filters when domains change (counters disabled, selection)", async function (assert) { + assert.expect(8); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <filter name="Filter" domain="[('id', '<', 5)]"/> + <searchpanel> + <field name="state" expand="1"/> + <field name="company_id" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + viewOptions: { + limit: 2, + }, + }); + + assert.verifySteps([ + 'search_panel_select_range', + 'search_panel_select_multi_range', + ]); + + // go to page 2 (the domain doesn't change, so the filters should not be reloaded) + await cpHelpers.pagerNext(kanban); + + assert.verifySteps([]); + + // reload with another domain, so the filters should be reloaded + await cpHelpers.toggleFilterMenu(kanban); + await cpHelpers.toggleMenuItem(kanban, 0); + + assert.verifySteps([ + 'search_panel_select_multi_range', + ]); + + // change category value, so the filters should be reloaded + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.verifySteps([ + 'search_panel_select_multi_range', + ]); + + kanban.destroy(); + }); + + QUnit.test("only reload categories and filters when domains change (counters disabled, many2one)", async function (assert) { + assert.expect(8); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <filter name="domain" domain="[('id', '<', 5)]"/> + <searchpanel> + <field name="category_id" expand="1"/> + <field name="company_id" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + viewOptions: { + limit: 2, + }, + }); + + assert.verifySteps([ + 'search_panel_select_range', + 'search_panel_select_multi_range', + ]); + + // go to page 2 (the domain doesn't change, so the filters should not be reloaded) + await cpHelpers.pagerNext(kanban); + + assert.verifySteps([]); + + // reload with another domain, so the filters should be reloaded + await cpHelpers.toggleFilterMenu(kanban); + await cpHelpers.toggleMenuItem(kanban, 0); + + assert.verifySteps([ + 'search_panel_select_multi_range', + ]); + + // change category value, so the filters should be reloaded + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.verifySteps([ + 'search_panel_select_multi_range', + ]); + + kanban.destroy(); + }); + + QUnit.test('category counters', async function (assert) { + assert.expect(16); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + if (route === "/web/dataset/call_kw/partner/search_panel_select_range") { + assert.step(args.args[0]); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <filter name="Filter" domain="[('id', '<', 3)]"/> + <searchpanel> + <field name="state" enable_counters="1" expand="1"/> + <field name="company_id" expand="1"/> + </searchpanel> + </search>`, + }, + viewOptions: { + limit: 2, + }, + }); + + assert.verifySteps([ + 'search_panel_select_range', + 'state', + 'search_panel_select_range', + 'company_id', + ]); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC1", "DEF1", "GHI2", "All", "asustek", "agrolait"] + ); + + // go to page 2 (the domain doesn't change, so the categories should not be reloaded) + await cpHelpers.pagerNext(kanban); + + assert.verifySteps([]); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC1", "DEF1", "GHI2", "All", "asustek", "agrolait"] + ); + + // reload with another domain, so the categories 'state' and 'company_id' should be reloaded + await cpHelpers.toggleFilterMenu(kanban); + await cpHelpers.toggleMenuItem(kanban, 0); + + assert.verifySteps([ + 'search_panel_select_range', + 'state', + ]); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC1", "DEF1", "GHI", "All", "asustek", "agrolait"] + ); + + // change category value, so the category 'state' should be reloaded + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC1", "DEF1", "GHI", "All", "asustek", "agrolait"] + ); + + assert.verifySteps([ + 'search_panel_select_range', + 'state', + ]); + + kanban.destroy(); + }); + + QUnit.test('category selection without counters', async function (assert) { + assert.expect(10); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + if (route === "/web/dataset/call_kw/partner/search_panel_select_range") { + assert.step(args.args[0]); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <filter name="Filter" domain="[('id', '<', 3)]"/> + <searchpanel> + <field name="state" expand="1"/> + </searchpanel> + </search>`, + }, + viewOptions: { + limit: 2, + }, + }); + + assert.verifySteps([ + 'search_panel_select_range', + 'state', + ]); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC", "DEF", "GHI"] + ); + + // go to page 2 (the domain doesn't change, so the categories should not be reloaded) + await cpHelpers.pagerNext(kanban); + + assert.verifySteps([]); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC", "DEF", "GHI"] + ); + + // reload with another domain, so the category 'state' should be reloaded + await cpHelpers.toggleFilterMenu(kanban); + await cpHelpers.toggleMenuItem(kanban, 0); + + assert.verifySteps([]); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC", "DEF", "GHI"] + ); + + // change category value, so the category 'state' should be reloaded + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map( + e => e.innerText.replace(/\s/g, '') + ), + [ "All", "ABC", "DEF", "GHI"] + ); + + assert.verifySteps([]); + + kanban.destroy(); + }); + + QUnit.test('filter with groupby', async function (assert) { + assert.expect(42); + + this.data.company.records.push({id: 11, name: 'camptocamp', category_id: 7}); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'search_panel_select_multi_range') { + // the following keys should have same value for all calls to this route + var keys = ['group_by', 'comodel_domain', 'search_domain', 'category_domain']; + assert.deepEqual(_.pick(args.kwargs, keys), { + group_by: 'category_id', + comodel_domain: [], + search_domain: [['bar', '=', true]], + category_domain: [], + }); + // the filter_domain depends on the filter selection + assert.step(JSON.stringify(args.kwargs.filter_domain)); + } + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" groupby="category_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + domain: [['bar', '=', true]], + }); + + assert.containsN(kanban, '.o_search_panel_filter_group', 2); + assert.containsOnce(kanban, '.o_search_panel_filter_group:first .o_search_panel_filter_value'); + assert.containsN(kanban, '.o_search_panel_filter_group:nth(1) .o_search_panel_filter_value', 2); + assert.containsNone(kanban, '.o_search_panel_filter_value input:checked'); + assert.strictEqual(kanban.$('.o_search_panel_filter_group > header > div > label').text().replace(/\s/g, ''), + 'goldsilver'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolait1camptocamp'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3); + + // check 'asustek' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input')); + + assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked'); + var firstGroupCheckbox = kanban.$('.o_search_panel_filter_group:first > header > div > input').get(0); + assert.strictEqual(firstGroupCheckbox.checked, true, + "first group checkbox should be checked"); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolaitcamptocamp'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2); + + // check 'agrolait' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input')); + + assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2); + var secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0); + assert.strictEqual(secondGroupCheckbox.checked, false, + "second group checkbox should not be checked"); + assert.strictEqual(secondGroupCheckbox.indeterminate, true, + "second group checkbox should be indeterminate"); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustekagrolaitcamptocamp'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 0); + + // check 'camptocamp' + await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(2) input')); + + assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 3); + secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0); + assert.strictEqual(secondGroupCheckbox.checked, true, + "second group checkbox should be checked"); + assert.strictEqual(secondGroupCheckbox.indeterminate, false, + "second group checkbox should not be indeterminate"); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustekagrolaitcamptocamp'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 0); + + // uncheck second group + await testUtils.dom.click(kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input')); + + assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked'); + secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0); + assert.strictEqual(secondGroupCheckbox.checked, false, + "second group checkbox should not be checked"); + assert.strictEqual(secondGroupCheckbox.indeterminate, false, + "second group checkbox should not be indeterminate"); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolaitcamptocamp'); + assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2); + + assert.verifySteps([ + // nothing checked + '[]', + '[["bar","=",true]]', + // 'asustek' checked + '[]', + '["&",["bar","=",true],["company_id","in",[3]]]', + // 'asustek' and 'agrolait' checked + '[]', + '["&",["bar","=",true],"&",["company_id","in",[3]],["company_id","in",[5]]]', + // 'asustek', 'agrolait' and 'camptocamp' checked + '[]', + '["&",["bar","=",true],"&",["company_id","in",[3]],["company_id","in",[5,11]]]', + // 'asustek' checked + '[]', + '["&",["bar","=",true],["company_id","in",[3]]]', + ]); + + kanban.destroy(); + }); + + QUnit.test('filter with domain', async function (assert) { + assert.expect(3); + + this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 3}); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'search_panel_select_multi_range') { + assert.deepEqual(args.kwargs, { + group_by: false, + category_domain: [], + expand: true, + filter_domain: [], + search_domain: [], + comodel_domain: [['parent_id', '=', false]], + group_domain: [], + enable_counters: true, + limit: 200, + }); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" domain="[('parent_id','=',False)]" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + }); + + assert.containsN(kanban, '.o_search_panel_filter_value', 2); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'asustek2agrolait2'); + + kanban.destroy(); + }); + + QUnit.test('filter with domain depending on category', async function (assert) { + assert.expect(22); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'search_panel_select_multi_range') { + // the following keys should have same value for all calls to this route + var keys = ['group_by', 'search_domain', 'filter_domain']; + assert.deepEqual(_.pick(args.kwargs, keys), { + group_by: false, + filter_domain: [], + search_domain: [], + }); + assert.step(JSON.stringify(args.kwargs.category_domain)); + assert.step(JSON.stringify(args.kwargs.comodel_domain)); + } + return result; + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="category_id" enable_counters="1"/> + <field name="company_id" select="multi" domain="[['category_id', '=', category_id]]" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + // select 'gold' category + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value .active'); + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active'); + assert.containsOnce(kanban, '.o_search_panel_filter_value'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + "asustek1"); + + // select 'silver' category + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active'); + assert.containsOnce(kanban, '.o_search_panel_filter_value'); + assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''), + "agrolait2"); + + // select All + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header')); + + assert.containsOnce(kanban, '.o_search_panel_category_value:first .active'); + assert.containsNone(kanban, '.o_search_panel_filter_value'); + + assert.verifySteps([ + '[]', // category_domain (All) + '[["category_id","=",false]]', // comodel_domain (All) + '[["category_id","=",6]]', // category_domain ('gold') + '[["category_id","=",6]]', // comodel_domain ('gold') + '[["category_id","=",7]]', // category_domain ('silver') + '[["category_id","=",7]]', // comodel_domain ('silver') + '[]', // category_domain (All) + '[["category_id","=",false]]', // comodel_domain (All) + ]); + + kanban.destroy(); + }); + + QUnit.test('specify active filter values in context', async function (assert) { + assert.expect(4); + + var expectedDomain = [ + "&", + ['company_id', 'in', [5]], + ['state', 'in', ['abc', 'ghi']], + ]; + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" enable_counters="1"/> + <field name="state" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, expectedDomain); + } + return this._super.apply(this, arguments); + }, + context: { + searchpanel_default_company_id: [5], + searchpanel_default_state: ['abc', 'ghi'], + }, + }); + + assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 3); + + // manually untick a default value + expectedDomain = [['state', 'in', ['abc', 'ghi']]]; + await testUtils.dom.click(kanban.$('.o_search_panel_filter:first .o_search_panel_filter_value:nth(1) input')); + + assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2); + + kanban.destroy(); + }); + + QUnit.test('retrieved filter value from context does not exist', async function (assert) { + assert.expect(1); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [["company_id", "in", [3]]]); + } + return this._super.apply(this, arguments); + }, + context: { + searchpanel_default_company_id: [1, 3], + }, + }); + + kanban.destroy(); + }); + + QUnit.test('filter with groupby and default values in context', async function (assert) { + assert.expect(2); + + this.data.company.records.push({id: 11, name: 'camptocamp', category_id: 7}); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" groupby="category_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [['company_id', 'in', [5]]]); + } + return this._super.apply(this, arguments); + }, + context: { + searchpanel_default_company_id: [5], + }, + }); + + var secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0); + assert.strictEqual(secondGroupCheckbox.indeterminate, true); + + kanban.destroy(); + }); + + QUnit.test('Does not confuse false and "false" groupby values', async function (assert) { + assert.expect(6); + + this.data.company.fields.char_field = {string: "Char Field", type: 'char'}; + + this.data.company.records = [ + {id: 3, name: 'A', char_field: false, }, + {id: 5, name: 'B', char_field: 'false', } + ]; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: `<kanban> + <templates><t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t></templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" groupby="char_field"/> + </searchpanel> + </search>`, + }, + }); + + assert.containsOnce(kanban, '.o_search_panel_section'); + var $firstSection = kanban.$('.o_search_panel_section'); + + // There should be a group 'false' displayed with only value B inside it. + assert.containsOnce($firstSection, '.o_search_panel_filter_group'); + assert.strictEqual($firstSection.find('.o_search_panel_filter_group').text().replace(/\s/g, ''), + 'falseB'); + assert.containsOnce($firstSection.find('.o_search_panel_filter_group'), '.o_search_panel_filter_value'); + + // Globally, there should be two values, one displayed in the group 'false', and one at the end of the section + // (the group false is not displayed and its values are displayed at the first level) + assert.containsN($firstSection, '.o_search_panel_filter_value', 2); + assert.strictEqual($firstSection.find('.o_search_panel_filter_value').text().replace(/\s/g, ''), + 'BA'); + + kanban.destroy(); + }); + + QUnit.test('tests conservation of category record order', async function (assert) { + assert.expect(1); + + this.data.company.records.push({id: 56, name: 'highID', category_id: 6}); + this.data.company.records.push({id: 2, name: 'lowID', category_id: 6}); + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1" expand="1"/> + <field name="category_id" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + }); + + var $firstSection = kanban.$('.o_search_panel_section:first'); + assert.strictEqual($firstSection.find('.o_search_panel_category_value').text().replace(/\s/g, ''), + 'Allasustek2agrolait2highIDlowID'); + kanban.destroy(); + }); + + QUnit.test('search panel is available on list and kanban by default', async function (assert) { + assert.expect(8); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + await actionManager.doAction(1); + + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + await cpHelpers.switchView(actionManager, 'pivot'); + await testUtils.nextTick(); + assert.containsOnce(actionManager, '.o_content .o_pivot'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + + await cpHelpers.switchView(actionManager, 'list'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_list_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_data_row .o_data_cell:first')); + assert.containsOnce(actionManager, '.o_content .o_form_view'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + + actionManager.destroy(); + }); + + QUnit.test('search panel with view_types attribute', async function (assert) { + assert.expect(6); + + this.archs['partner,false,search'] = + `<search> + <searchpanel view_types="kanban,pivot"> + <field name="company_id" enable_counters="1"/> + <field name="category_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`; + + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + await actionManager.doAction(1); + + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + await cpHelpers.switchView(actionManager, 'list'); + assert.containsOnce(actionManager, '.o_content .o_list_view'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + + await cpHelpers.switchView(actionManager, 'pivot'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_pivot'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + actionManager.destroy(); + }); + + QUnit.test('search panel state is shared between views', async function (assert) { + assert.expect(16); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + }); + await actionManager.doAction(1); + + assert.hasClass(actionManager.$('.o_search_panel_category_value:first header'), 'active'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + // select 'asustek' company + await testUtils.dom.click(actionManager.$('.o_search_panel_category_value:nth(1) header')); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(1) header'), 'active'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + await cpHelpers.switchView(actionManager, 'list'); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(1) header'), 'active'); + assert.containsN(actionManager, '.o_data_row', 2); + + // select 'agrolait' company + await testUtils.dom.click(actionManager.$('.o_search_panel_category_value:nth(2) header')); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(2) header'), 'active'); + assert.containsN(actionManager, '.o_data_row', 2); + + await cpHelpers.switchView(actionManager, 'kanban'); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(2) header'), 'active'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + assert.verifySteps([ + '[]', // initial search_read + '[["company_id","child_of",3]]', // kanban, after selecting the first company + '[["company_id","child_of",3]]', // list + '[["company_id","child_of",5]]', // list, after selecting the other company + '[["company_id","child_of",5]]', // kanban + ]); + + actionManager.destroy(); + }); + + QUnit.test('search panel filters are kept between switch views', async function (assert) { + assert.expect(17); + + const actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + }); + await actionManager.doAction(1); + + assert.containsNone(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + // select gold filter + await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(0)')); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + await cpHelpers.switchView(actionManager, 'list'); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_data_row', 1); + + // select silver filter + await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(1)')); + assert.containsN(actionManager, '.o_search_panel_filter_value input:checked', 2); + assert.containsN(actionManager, '.o_data_row', 4); + + await cpHelpers.switchView(actionManager, 'kanban'); + assert.containsN(actionManager, '.o_search_panel_filter_value input:checked', 2); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + await testUtils.dom.click(actionManager.$(".o_kanban_record:nth(0)")); + await testUtils.dom.click(actionManager.$(".breadcrumb-item:nth(0)")); + + assert.verifySteps([ + '[]', // initial search_read + '[["category_id","in",[6]]]', // kanban, after selecting the gold filter + '[["category_id","in",[6]]]', // list + '[["category_id","in",[6,7]]]', // list, after selecting the silver filter + '[["category_id","in",[6,7]]]', // kanban + '[["category_id","in",[6,7]]]', // kanban, after switching back from form view + ]); + + actionManager.destroy(); + }); + + QUnit.test('search panel filters are kept when switching to a view with no search panel', async function (assert) { + assert.expect(13); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + await actionManager.doAction(1); + + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + assert.containsNone(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + // select gold filter + await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(0)')); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // switch to pivot + await cpHelpers.switchView(actionManager, 'pivot'); + assert.containsOnce(actionManager, '.o_content .o_pivot'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + assert.strictEqual(actionManager.$('.o_pivot_cell_value').text(), '15'); + + // switch to list + await cpHelpers.switchView(actionManager, 'list'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_list_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_data_row', 1); + + actionManager.destroy(); + }); + + QUnit.test('after onExecuteAction, selects "All" as default category value', async function (assert) { + assert.expect(4); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + await actionManager.doAction(2); + await testUtils.dom.click(actionManager.$('.o_form_view button:contains("multi view")')); + + assert.containsOnce(actionManager, '.o_kanban_view'); + assert.containsOnce(actionManager, '.o_search_panel'); + assert.containsOnce(actionManager, '.o_search_panel_category_value:first .active'); + + assert.verifySteps([]); // should not communicate with localStorage + + actionManager.destroy(); + }); + + QUnit.test('search panel is not instantiated if stated in context', async function (assert) { + assert.expect(2); + + this.actions[0].context = {search_panel: false}; + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + await actionManager.doAction(2); + await testUtils.dom.click(actionManager.$('.o_form_view button:contains("multi view")')); + + assert.containsOnce(actionManager, '.o_kanban_view'); + assert.containsNone(actionManager, '.o_search_panel'); + + actionManager.destroy(); + }); + + QUnit.test('categories and filters are not reloaded when switching between views', async function (assert) { + assert.expect(3); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + }); + await actionManager.doAction(1); + + await cpHelpers.switchView(actionManager, 'list'); + await cpHelpers.switchView(actionManager, 'kanban'); + + assert.verifySteps([ + 'search_panel_select_range', // kanban: categories + 'search_panel_select_multi_range', // kanban: filters + ]); + + actionManager.destroy(); + }); + + QUnit.test('scroll position is kept when switching between controllers', async function (assert) { + assert.expect(6); + + const originalDebounce = SearchPanel.scrollDebounce; + SearchPanel.scrollDebounce = 0; + + for (var i = 10; i < 20; i++) { + this.data.category.records.push({id: i, name: "Cat " + i}); + } + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.$el.css('max-height', 300); + + async function scroll(top) { + actionManager.el.querySelector(".o_search_panel").scrollTop = top; + await testUtils.nextTick(); + } + + await actionManager.doAction(1); + + assert.containsOnce(actionManager, '.o_content .o_kanban_view'); + assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 0); + + // simulate a scroll in the search panel and switch into list + await scroll(50); + await cpHelpers.switchView(actionManager, 'list'); + assert.containsOnce(actionManager, '.o_content .o_list_view'); + assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 50); + + // simulate another scroll and switch back to kanban + await scroll(30); + await cpHelpers.switchView(actionManager, 'kanban'); + assert.containsOnce(actionManager, '.o_content .o_kanban_view'); + assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 30); + + actionManager.destroy(); + SearchPanel.scrollDebounce = originalDebounce; + }); + + QUnit.test('search panel is not instantiated in dialogs', async function (assert) { + assert.expect(2); + + this.data.company.records = [ + {id: 1, name: 'Company1'}, + {id: 2, name: 'Company2'}, + {id: 3, name: 'Company3'}, + {id: 4, name: 'Company4'}, + {id: 5, name: 'Company5'}, + {id: 6, name: 'Company6'}, + {id: 7, name: 'Company7'}, + {id: 8, name: 'Company8'}, + ]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="company_id"/></form>', + archs: { + 'company,false,list': '<tree><field name="name"/></tree>', + 'company,false,search': + `<search> + <field name="name"/> + <searchpanel> + <field name="category_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + await testUtils.fields.many2one.clickOpenDropdown('company_id'); + await testUtils.fields.many2one.clickItem('company_id', 'Search More'); + + assert.containsOnce(document.body, '.modal .o_list_view'); + assert.containsNone(document.body, '.modal .o_search_panel'); + + form.destroy(); + }); + + + QUnit.test("Reload categories with counters when filter values are selected", async function (assert) { + assert.expect(8); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="category_id" enable_counters="1"/> + <field name="state" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + assert.verifySteps([ + 'search_panel_select_range', + "search_panel_select_multi_range", + ]); + + assert.deepEqual(getCounters(kanban), [ + 1, 3, // category counts (in order) + 1, 1, 2 // filter counts + ]); + + await testUtils.dom.click(kanban.el.querySelector('.o_search_panel_filter_value input')); + + assert.deepEqual(getCounters(kanban), [ + 1, // category counts (for silver: 0 is not displayed) + 1, 1, 2 // filter counts + ]); + + assert.verifySteps([ + 'search_panel_select_range', + "search_panel_select_multi_range", + ]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, expand, hierarchize, counters", async function (assert) { + assert.expect(5); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3); + assert.containsOnce(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2 ,1]); + + await toggleFold(kanban, "agrolait"); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5); + assert.deepEqual(getCounters(kanban), [2, 1, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, no expand, hierarchize, counters", async function (assert) { + assert.expect(5); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3); + assert.containsOnce(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 1]); + + await toggleFold(kanban, "agrolait"); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4); + assert.deepEqual(getCounters(kanban), [2, 1, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, expand, no hierarchize, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" hierarchize="0" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 1, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, no expand, no hierarchize, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" hierarchize="0" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 1, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, expand, hierarchize, no counters", async function (assert) { + assert.expect(5); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3); + assert.containsOnce(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + await toggleFold(kanban, "agrolait"); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, no expand, hierarchize, no counters", async function (assert) { + assert.expect(5); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3); + assert.containsOnce(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + await toggleFold(kanban, "agrolait"); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, expand, no hierarchize, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" hierarchize="0" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2one: select one, no expand, no hierarchize, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 }); + this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 }); + this.data.partner.records[1].company_id = 50; + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" hierarchize="0"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, expand, groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" groupby="category_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 5); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 2]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, no expand, groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" groupby="category_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 2]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, expand, no groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 2]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, no expand, no groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 2); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 2]); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, expand, groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" groupby="category_id" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 5); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, no expand, groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" groupby="category_id"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, expand, no groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2one: select multi, no expand, no groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 2); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, expand, groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi" groupby="category_id" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 5); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, no expand, groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi" groupby="category_id" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, expand, no groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, no expand, no groupby, counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 2); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [2, 1]); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, expand, groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi" groupby="category_id" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 5); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, no expand, groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi" groupby="category_id"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, expand, no groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("many2many: select multi, no expand, no groupby, no counters", async function (assert) { + assert.expect(3); + + this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 }); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_ids" select="multi"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 2); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("selection: select one, expand, counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [1, 2]); + + kanban.destroy(); + }); + + QUnit.test("selection: select one, no expand, counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [1, 2]); + + kanban.destroy(); + }); + + QUnit.test("selection: select one, expand, no counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("selection: select one, no expand, no counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("selection: select multi, expand, counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" select="multi" enable_counters="1" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [1, 2]); + + kanban.destroy(); + }); + + QUnit.test("selection: select multi, no expand, counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 2); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [1, 2]); + + kanban.destroy(); + }); + + QUnit.test("selection: select multi, expand, no counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" select="multi" expand="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 3); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + QUnit.test("selection: select multi, no expand, no counters", async function (assert) { + assert.expect(3); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="state" select="multi"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 2); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), []); + + kanban.destroy(); + }); + + //------------------------------------------------------------------------- + // Model domain and count domain distinction + //------------------------------------------------------------------------- + + QUnit.test("selection: select multi, no expand, counters, extra_domain", async function (assert) { + assert.expect(5); + + this.data.partner.records.shift(); + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id"/> + <field name="state" select="multi" enable_counters="1"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsN(kanban, '.o_search_panel_label', 5); + assert.containsNone(kanban, '.o_toggle_fold > i'); + assert.deepEqual(getCounters(kanban), [1, 2]); + + await toggleFold(kanban, "asustek"); + + assert.containsN(kanban, '.o_search_panel_label', 5); + assert.deepEqual(getCounters(kanban), [1]); + + kanban.destroy(); + }); + + //------------------------------------------------------------------------- + // Limit + //------------------------------------------------------------------------- + + QUnit.test("reached limit for a category", async function (assert) { + assert.expect(6); + + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" limit="2"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsOnce(kanban, '.o_search_panel_section'); + assert.containsOnce(kanban, '.o_search_panel_section_header'); + assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, "COMPANY"); + assert.containsOnce(kanban, 'section div.alert.alert-warning'); + assert.strictEqual(kanban.el.querySelector('section div.alert.alert-warning').innerText, "Too many items to display."); + assert.containsNone(kanban, '.o_search_panel_category_value'); + + kanban.destroy(); + }); + + QUnit.test("reached limit for a filter", async function (assert) { + assert.expect(6); + + const kanban = await createView({ + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="company_id" select="multi" limit="2"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: 'partner', + View: KanbanView, + }); + + assert.containsOnce(kanban, '.o_search_panel_section'); + assert.containsOnce(kanban, '.o_search_panel_section_header'); + assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, "COMPANY"); + assert.containsOnce(kanban, 'section div.alert.alert-warning'); + assert.strictEqual(kanban.el.querySelector('section div.alert.alert-warning').innerText, "Too many items to display."); + assert.containsNone(kanban, '.o_search_panel_filter_value'); + + kanban.destroy(); + }); + + QUnit.test("a selected value becomming invalid should no more impact the view", async function (assert) { + assert.expect(13); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <filter name="filter_on_def" string="DEF" domain="[('state', '=', 'def')]"/> + <searchpanel> + <field name="state" enable_counters="1"/> + </searchpanel> + </search>`, + }, + }); + + assert.verifySteps([ + 'search_panel_select_range', + ]); + + assert.containsN(kanban, '.o_kanban_record span', 4); + + // select 'ABC' in search panel + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.verifySteps([ + 'search_panel_select_range', + ]); + + assert.containsOnce(kanban, '.o_kanban_record span'); + assert.strictEqual(kanban.el.querySelector('.o_kanban_record span').innerText, 'yop' ); + + // select DEF in filter menu + await testUtils.controlPanel.toggleFilterMenu(kanban); + await testUtils.controlPanel.toggleMenuItem(kanban, 'DEF'); + + assert.verifySteps([ + 'search_panel_select_range', + ]); + + const firstCategoryValue = kanban.el.querySelector('.o_search_panel_category_value header'); + assert.strictEqual(firstCategoryValue.innerText, 'All'); + assert.hasClass( + firstCategoryValue, 'active', + "the value 'All' should be selected since ABC is no longer a valid value with respect to search domain" + ); + assert.containsOnce(kanban, '.o_kanban_record span'); + assert.strictEqual(kanban.el.querySelector('.o_kanban_record span').innerText, 'blip' ); + + kanban.destroy(); + }); + + QUnit.test("Categories with default attributes should be udpated when external domain changes", async function (assert) { + assert.expect(8); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + mockRPC: function (route, args) { + if (args.method && args.method.includes('search_panel_')) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': ` + <search> + <filter name="filter_on_def" string="DEF" domain="[('state', '=', 'def')]"/> + <searchpanel> + <field name="state"/> + </searchpanel> + </search>`, + }, + }); + + assert.verifySteps([ + 'search_panel_select_range', + ]); + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value header label')].map(el => el.innerText), + ['All', 'ABC', 'DEF', 'GHI'] + ); + + // select 'ABC' in search panel --> no need to update the category value + await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header')); + + assert.verifySteps([]); + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value header label')].map(el => el.innerText), + ['All', 'ABC', 'DEF', 'GHI'] + ); + + // select DEF in filter menu --> the external domain changes --> the values should be updated + await testUtils.controlPanel.toggleFilterMenu(kanban); + await testUtils.controlPanel.toggleMenuItem(kanban, 'DEF'); + + assert.verifySteps([ + 'search_panel_select_range', + ]); + assert.deepEqual( + [...kanban.el.querySelectorAll('.o_search_panel_category_value header label')].map(el => el.innerText), + ['All', 'DEF'] + ); + + kanban.destroy(); + }); + + QUnit.test("Category with counters and filter with domain", async function (assert) { + assert.expect(2); + + const list = await createView({ + arch: '<tree><field name="foo"/></tree>', + archs: { + 'partner,false,search': ` + <search> + <searchpanel> + <field name="category_id" enable_counters="1"/> + <field name="company_id" select="multi" domain="[['category_id', '=', category_id]]"/> + </searchpanel> + </search>`, + }, + data: this.data, + model: "partner", + services: this.services, + View: ListView, + }); + + assert.containsN(list, ".o_data_row", 4); + assert.strictEqual( + list.$(".o_search_panel_category_value").text().replace(/\s/g, ""), + "Allgoldsilver", + "Category counters should be empty if a filter has a domain attribute" + ); + + list.destroy(); + }); +}); +}); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js new file mode 100644 index 00000000..046fb873 --- /dev/null +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -0,0 +1,615 @@ +odoo.define('web.view_dialogs_tests', function (require) { +"use strict"; + +var dialogs = require('web.view_dialogs'); +var ListController = require('web.ListController'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); +var FormView = require('web.FormView'); + +const cpHelpers = testUtils.controlPanel; +var createView = testUtils.createView; + +async function createParent(params) { + var widget = new Widget(); + params.server = await testUtils.mock.addMockEnvironment(widget, params); + return widget; +} + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + display_name: { string: "Displayed name", type: "char" }, + foo: {string: "Foo", type: 'char'}, + bar: {string: "Bar", type: "boolean"}, + instrument: {string: 'Instruments', type: 'many2one', relation: 'instrument'}, + }, + records: [ + {id: 1, foo: 'blip', display_name: 'blipblip', bar: true}, + {id: 2, foo: 'ta tata ta ta', display_name: 'macgyver', bar: false}, + {id: 3, foo: 'piou piou', display_name: "Jack O'Neill", bar: true}, + ], + }, + instrument: { + fields: { + name: {string: "name", type: "char"}, + badassery: {string: 'level', type: 'many2many', relation: 'badassery', domain: [['level', '=', 'Awsome']]}, + }, + }, + + badassery: { + fields: { + level: {string: 'level', type: "char"}, + }, + records: [ + {id: 1, level: 'Awsome'}, + ], + }, + + product: { + fields : { + name: {string: "name", type: "char" }, + partner : {string: 'Doors', type: 'one2many', relation: 'partner'}, + }, + records: [ + {id: 1, name: 'The end'}, + ], + }, + }; + }, +}, function () { + + QUnit.module('view_dialogs'); + + QUnit.test('formviewdialog buttons in footer are positioned properly', async function (assert) { + assert.expect(2); + + var parent = await createParent({ + data: this.data, + archs: { + 'partner,false,form': + '<form string="Partner">' + + '<sheet>' + + '<group><field name="foo"/></group>' + + '<footer><button string="Custom Button" type="object" class="btn-primary"/></footer>' + + '</sheet>' + + '</form>', + }, + }); + + new dialogs.FormViewDialog(parent, { + res_model: 'partner', + res_id: 1, + }).open(); + await testUtils.nextTick(); + + assert.notOk($('.modal-body button').length, + "should not have any button in body"); + assert.strictEqual($('.modal-footer button').length, 1, + "should have only one button in footer"); + parent.destroy(); + }); + + QUnit.test('formviewdialog buttons in footer are not duplicated', async function (assert) { + assert.expect(2); + this.data.partner.fields.poney_ids = {string: "Poneys", type: "one2many", relation: 'partner'}; + this.data.partner.records[0].poney_ids = []; + + var parent = await createParent({ + data: this.data, + archs: { + 'partner,false,form': + '<form string="Partner">' + + '<field name="poney_ids"><tree editable="top"><field name="display_name"/></tree></field>' + + '<footer><button string="Custom Button" type="object" class="btn-primary"/></footer>' + + '</form>', + }, + }); + + new dialogs.FormViewDialog(parent, { + res_model: 'partner', + res_id: 1, + }).open(); + await testUtils.nextTick(); + + assert.strictEqual($('.modal button.btn-primary').length, 1, + "should have 1 buttons in modal"); + + await testUtils.dom.click($('.o_field_x2many_list_row_add a')); + await testUtils.fields.triggerKeydown($('input.o_input'), 'escape'); + + assert.strictEqual($('.modal button.btn-primary').length, 1, + "should still have 1 buttons in modal"); + parent.destroy(); + }); + + QUnit.test('SelectCreateDialog use domain, group_by and search default', async function (assert) { + assert.expect(3); + + var search = 0; + var parent = await createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree string="Partner">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>', + 'partner,false,search': + '<search>' + + '<field name="foo" filter_domain="[(\'display_name\',\'ilike\',self), (\'foo\',\'ilike\',self)]"/>' + + '<group expand="0" string="Group By">' + + '<filter name="groupby_bar" context="{\'group_by\' : \'bar\'}"/>' + + '</group>' + + '</search>', + }, + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + assert.deepEqual(args.kwargs, { + context: { + search_default_foo: "piou", + search_default_groupby_bar: true, + }, + domain: ["&", ["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]], + fields: ["display_name", "foo", "bar"], + groupby: ["bar"], + orderby: '', + lazy: true, + limit: 80, + }, "should search with the complete domain (domain + search), and group by 'bar'"); + } + if (search === 0 && route === '/web/dataset/search_read') { + search++; + assert.deepEqual(args, { + context: { + search_default_foo: "piou", + search_default_groupby_bar: true, + bin_size: true + }, // not part of the test, may change + domain: ["&", ["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]], + fields: ["display_name", "foo"], + model: "partner", + limit: 80, + sort: "" + }, "should search with the complete domain (domain + search)"); + } else if (search === 1 && route === '/web/dataset/search_read') { + assert.deepEqual(args, { + context: { + search_default_foo: "piou", + search_default_groupby_bar: true, + bin_size: true + }, // not part of the test, may change + domain: [["display_name", "like", "a"]], + fields: ["display_name", "foo"], + model: "partner", + limit: 80, + sort: "" + }, "should search with the domain"); + } + + return this._super.apply(this, arguments); + }, + }); + + var dialog; + new dialogs.SelectCreateDialog(parent, { + no_create: true, + readonly: true, + res_model: 'partner', + domain: [['display_name', 'like', 'a']], + context: { + search_default_groupby_bar: true, + search_default_foo: 'piou', + }, + }).open().then(function (result) { + dialog = result; + }); + await testUtils.nextTick(); + await cpHelpers.removeFacet('.modal', "Bar"); + await cpHelpers.removeFacet('.modal'); + + parent.destroy(); + }); + + QUnit.test('SelectCreateDialog correctly evaluates domains', async function (assert) { + assert.expect(1); + + var parent = await createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree string="Partner">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>', + 'partner,false,search': + '<search>' + + '<field name="foo"/>' + + '</search>', + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [['id', '=', 2]], + "should have correctly evaluated the domain"); + } + return this._super.apply(this, arguments); + }, + session: { + user_context: {uid: 2}, + }, + }); + + new dialogs.SelectCreateDialog(parent, { + no_create: true, + readonly: true, + res_model: 'partner', + domain: "[['id', '=', uid]]", + }).open(); + await testUtils.nextTick(); + + parent.destroy(); + }); + + QUnit.test('SelectCreateDialog list view in readonly', async function (assert) { + assert.expect(1); + + var parent = await createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree string="Partner" editable="bottom">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>', + 'partner,false,search': + '<search/>' + }, + }); + + var dialog; + new dialogs.SelectCreateDialog(parent, { + res_model: 'partner', + }).open().then(function (result) { + dialog = result; + }); + await testUtils.nextTick(); + + // click on the first row to see if the list is editable + await testUtils.dom.click(dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first')); + + assert.equal(dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first input').length, 0, + "list view should not be editable in a SelectCreateDialog"); + + parent.destroy(); + }); + + QUnit.test('SelectCreateDialog cascade x2many in create mode', async function (assert) { + assert.expect(5); + + var form = await createView({ + View: FormView, + model: 'product', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="partner" widget="one2many" >' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '<field name="instrument"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + archs: { + 'partner,false,form': '<form>' + + '<field name="name"/>' + + '<field name="instrument" widget="one2many" mode="tree"/>' + + '</form>', + + 'instrument,false,form': '<form>'+ + '<field name="name"/>'+ + '<field name="badassery">' + + '<tree>'+ + '<field name="level"/>'+ + '</tree>' + + '</field>' + + '</form>', + + 'badassery,false,list': '<tree>'+ + '<field name="level"/>'+ + '</tree>', + + 'badassery,false,search': '<search>'+ + '<field name="level"/>'+ + '</search>', + }, + + mockRPC: function(route, args) { + if (route === '/web/dataset/call_kw/partner/get_formview_id') { + return Promise.resolve(false); + } + if (route === '/web/dataset/call_kw/instrument/get_formview_id') { + return Promise.resolve(false); + } + if (route === '/web/dataset/call_kw/instrument/create') { + assert.deepEqual(args.args, [{badassery: [[6, false, [1]]], name: "ABC"}], + 'The method create should have been called with the right arguments'); + return Promise.resolve(false); + } + return this._super(route, args); + }, + }); + + await testUtils.form.clickEdit(form); + await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); + await testUtils.fields.many2one.createAndEdit("instrument"); + + var $modal = $('.modal-lg'); + + assert.equal($modal.length, 1, + 'There should be one modal'); + + await testUtils.dom.click($modal.find('.o_field_x2many_list_row_add a')); + + var $modals = $('.modal-lg'); + + assert.equal($modals.length, 2, + 'There should be two modals'); + + var $second_modal = $modals.not($modal); + await testUtils.dom.click($second_modal.find('.o_list_table.table.table-sm.table-striped.o_list_table_ungrouped .o_data_row input[type=checkbox]')); + + await testUtils.dom.click($second_modal.find('.o_select_button')); + + $modal = $('.modal-lg'); + + assert.equal($modal.length, 1, + 'There should be one modal'); + + assert.equal($modal.find('.o_data_cell').text(), 'Awsome', + 'There should be one item in the list of the modal'); + + await testUtils.dom.click($modal.find('.btn.btn-primary')); + + form.destroy(); + }); + + QUnit.test('Form dialog and subview with _view_ref contexts', async function (assert) { + assert.expect(2); + + this.data.instrument.records = [{id: 1, name: 'Tromblon', badassery: [1]}]; + this.data.partner.records[0].instrument = 1; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="instrument" context="{\'tree_view_ref\': \'some_tree_view\'}"/>' + + '</form>', + res_id: 1, + archs: { + 'instrument,false,form': '<form>'+ + '<field name="name"/>'+ + '<field name="badassery" context="{\'tree_view_ref\': \'some_other_tree_view\'}"/>' + + '</form>', + + 'badassery,false,list': '<tree>'+ + '<field name="level"/>'+ + '</tree>', + }, + viewOptions: { + mode: 'edit', + }, + + mockRPC: function(route, args) { + if (args.method === 'get_formview_id') { + return Promise.resolve(false); + } + return this._super(route, args); + }, + + interceptsPropagate: { + load_views: function (ev) { + var evaluatedContext = ev.data.context; + if (ev.data.modelName === 'instrument') { + assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_tree_view'}, + 'The correct _view_ref should have been sent to the server, first time'); + } + if (ev.data.modelName === 'badassery') { + assert.deepEqual(evaluatedContext, { + base_model_name: 'instrument', + tree_view_ref: 'some_other_tree_view', + }, 'The correct _view_ref should have been sent to the server for the subview'); + } + }, + }, + }); + + await testUtils.dom.click(form.$('.o_field_widget[name="instrument"] button.o_external_button')); + form.destroy(); + }); + + QUnit.test('SelectCreateDialog: save current search', async function (assert) { + assert.expect(4); + + testUtils.mock.patch(ListController, { + getOwnedQueryParams: function () { + return { + context: { + shouldBeInFilterContext: true, + }, + }; + }, + }); + + var parent = await createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree>' + + '<field name="display_name"/>' + + '</tree>', + 'partner,false,search': + '<search>' + + '<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', True)]"/>' + + '</search>', + + }, + env: { + dataManager: { + create_filter: function (filter) { + assert.strictEqual(filter.domain, `[("bar", "=", True)]`, + "should save the correct domain"); + const expectedContext = { + group_by: [], // default groupby is an empty list + shouldBeInFilterContext: true, + }; + assert.deepEqual(filter.context, expectedContext, + "should save the correct context"); + }, + } + }, + }); + + var dialog; + new dialogs.SelectCreateDialog(parent, { + context: {shouldNotBeInFilterContext: false}, + res_model: 'partner', + }).open().then(function (result) { + dialog = result; + }); + await testUtils.nextTick(); + + + assert.containsN(dialog, '.o_data_row', 3, "should contain 3 records"); + + // filter on bar + await cpHelpers.toggleFilterMenu('.modal'); + await cpHelpers.toggleMenuItem('.modal', "Bar"); + + assert.containsN(dialog, '.o_data_row', 2, "should contain 2 records"); + + // save filter + await cpHelpers.toggleFavoriteMenu('.modal'); + await cpHelpers.toggleSaveFavorite('.modal'); + await cpHelpers.editFavoriteName('.modal', "some name"); + await cpHelpers.saveFavorite('.modal'); + + testUtils.mock.unpatch(ListController); + parent.destroy(); + }); + + QUnit.test('propagate can_create onto the search popup o2m', async function (assert) { + assert.expect(4); + + this.data.instrument.records = [ + {id: 1, name: 'Tromblon1'}, + {id: 2, name: 'Tromblon2'}, + {id: 3, name: 'Tromblon3'}, + {id: 4, name: 'Tromblon4'}, + {id: 5, name: 'Tromblon5'}, + {id: 6, name: 'Tromblon6'}, + {id: 7, name: 'Tromblon7'}, + {id: 8, name: 'Tromblon8'}, + ]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="instrument" can_create="false"/>' + + '</form>', + res_id: 1, + archs: { + 'instrument,false,list': '<tree>'+ + '<field name="name"/>'+ + '</tree>', + 'instrument,false,search': '<search>'+ + '<field name="name"/>'+ + '</search>', + }, + viewOptions: { + mode: 'edit', + }, + + mockRPC: function(route, args) { + if (args.method === 'get_formview_id') { + return Promise.resolve(false); + } + return this._super(route, args); + }, + }); + + await testUtils.fields.many2one.clickOpenDropdown('instrument'); + + assert.containsNone(form, '.ui-autocomplete a:contains(Start typing...)'); + + await testUtils.fields.editInput(form.el.querySelector(".o_field_many2one[name=instrument] input"), "a"); + + assert.containsNone(form, '.ui-autocomplete a:contains(Create and Edit)'); + + await testUtils.fields.editInput(form.el.querySelector(".o_field_many2one[name=instrument] input"), ""); + await testUtils.fields.many2one.clickItem('instrument', 'Search More...'); + + var $modal = $('.modal-dialog.modal-lg'); + + assert.strictEqual($modal.length, 1, 'Modal present'); + + assert.strictEqual($modal.find('.modal-footer button').text(), "Cancel", + 'Only the cancel button is present in modal'); + + form.destroy(); + }); + + QUnit.test('formviewdialog is not closed when button handlers return a rejected promise', async function (assert) { + assert.expect(3); + + this.data.partner.fields.poney_ids = { string: "Poneys", type: "one2many", relation: 'partner' }; + this.data.partner.records[0].poney_ids = []; + var reject = true; + + var parent = await createParent({ + data: this.data, + archs: { + 'partner,false,form': + '<form string="Partner">' + + '<field name="poney_ids"><tree><field name="display_name"/></tree></field>' + + '</form>', + }, + }); + + new dialogs.FormViewDialog(parent, { + res_model: 'partner', + res_id: 1, + buttons: [{ + text: 'Click me !', + classes: "btn-secondary o_form_button_magic", + close: true, + click: function () { + return reject ? Promise.reject() : Promise.resolve(); + }, + }], + }).open(); + + await testUtils.nextTick(); + assert.strictEqual($('.modal').length, 1, "should have a modal displayed"); + + await testUtils.dom.click($('.modal .o_form_button_magic')); + assert.strictEqual($('.modal').length, 1, "modal should still be opened"); + + reject = false; + await testUtils.dom.click($('.modal .o_form_button_magic')); + assert.strictEqual($('.modal').length, 0, "modal should be closed"); + + parent.destroy(); + }); + +}); + +}); |
