From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- .../tests/views/abstract_controller_tests.js | 168 + .../web/static/tests/views/abstract_model_tests.js | 130 + .../tests/views/abstract_view_banner_tests.js | 108 + .../web/static/tests/views/abstract_view_tests.js | 146 + addons/web/static/tests/views/basic_model_tests.js | 2533 ++++ addons/web/static/tests/views/calendar_tests.js | 3883 ++++++ addons/web/static/tests/views/form_benchmarks.js | 108 + addons/web/static/tests/views/form_tests.js | 9907 ++++++++++++++++ addons/web/static/tests/views/graph_tests.js | 2048 ++++ addons/web/static/tests/views/kanban_benchmarks.js | 92 + .../web/static/tests/views/kanban_model_tests.js | 380 + addons/web/static/tests/views/kanban_tests.js | 7248 ++++++++++++ addons/web/static/tests/views/list_benchmarks.js | 113 + addons/web/static/tests/views/list_tests.js | 11702 +++++++++++++++++++ addons/web/static/tests/views/pivot_tests.js | 3294 ++++++ addons/web/static/tests/views/qweb_tests.js | 72 + .../web/static/tests/views/sample_server_tests.js | 486 + .../web/static/tests/views/search_panel_tests.js | 4173 +++++++ .../web/static/tests/views/view_dialogs_tests.js | 615 + 19 files changed, 47206 insertions(+) create mode 100644 addons/web/static/tests/views/abstract_controller_tests.js create mode 100644 addons/web/static/tests/views/abstract_model_tests.js create mode 100644 addons/web/static/tests/views/abstract_view_banner_tests.js create mode 100644 addons/web/static/tests/views/abstract_view_tests.js create mode 100644 addons/web/static/tests/views/basic_model_tests.js create mode 100644 addons/web/static/tests/views/calendar_tests.js create mode 100644 addons/web/static/tests/views/form_benchmarks.js create mode 100644 addons/web/static/tests/views/form_tests.js create mode 100644 addons/web/static/tests/views/graph_tests.js create mode 100644 addons/web/static/tests/views/kanban_benchmarks.js create mode 100644 addons/web/static/tests/views/kanban_model_tests.js create mode 100644 addons/web/static/tests/views/kanban_tests.js create mode 100644 addons/web/static/tests/views/list_benchmarks.js create mode 100644 addons/web/static/tests/views/list_tests.js create mode 100644 addons/web/static/tests/views/pivot_tests.js create mode 100644 addons/web/static/tests/views/qweb_tests.js create mode 100644 addons/web/static/tests/views/sample_server_tests.js create mode 100644 addons/web/static/tests/views/search_panel_tests.js create mode 100644 addons/web/static/tests/views/view_dialogs_tests.js (limited to 'addons/web/static/tests/views') 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 = + "
" + + 'simple' + + '' + + "child" + + "" + + 'method' + + 'descr' + + 'descr2' + + "
"; + + var view = await createView({ + View: getHtmlView(html, "test"), + data: this.data, + model: "test_model", + arch: "", + 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`
Test
`; + + var view = await createView({ + View: getOwlView(Renderer, "test"), + data: this.data, + model: "test_model", + arch: "", + }); + 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`
Test
`; + + var view = await createView({ + View: getOwlView(Renderer, "test"), + data: this.data, + model: "test_model", + arch: "", + }); + 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`
Test
`; + + const view = await createView({ + View: getOwlView(Renderer, "test"), + data: this.data, + model: "test_model", + arch: "", + }); + + 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 =` + +
+
+ + + +
+
+ +
Here is the banner
+
+
`; + + createView({ + View: TestView, + model: 'test_model', + data: this.data, + arch: '', + 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: '', + 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: '', + 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': '', + 'foo,false,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: ` +
+ + + `, + archs: { + 'product,false,list': ` + + + + `, + 'product,false,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: ` +
+ + + `, + archs: { + 'product,false,list': ` + + + + + `, + }, + 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": + '
'+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + '', + "event,1,form": + '
' + + '' + + ''+ + ''+ + ''+ + ''+ + '', + 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: + '', + 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': '', + 'event,false,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: + '', + 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: + '', + 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: + ''+ + ''+ + '', + 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: + ''+ + ''+ + ''+ + ''+ + '', + 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: + ''+ + '', + 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: + ''+ + '', + 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: + ''+ + '', + 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: + ''+ + ''+ + ''+ + '', + 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: + ''+ + ''+ + '', + 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: + ''+ + '', + 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: + ''+ + ''+ + '', + 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: + ''+ + ''+ + ''+ + ''+ + '', + 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: + ''+ + ''+ + ''+ + ''+ + '', + 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: + ''+ + ''+ + '', + 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: + '', + 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: + ''+ + ''+ + '', + 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: + ``, + 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: + '', + 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: + ''+ + ''+ + '', + 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: + '', + 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: + ' '+ + ''+ + '', + 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: + '', + 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: + '', + 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: + '', + 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: + '', + 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: + '', + 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: + '', + 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: + ''+ + ''+ + ''+ + '', + 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: + ''+ + ''+ + ''+ + '', + 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: + ''+ + ''+ + ''+ + ''+ + '', + 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: + ''+ + ''+ + ''+ + ''+ + '', + archs: { + "event,false,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: + ''+ + ''+ + ''+ + ''+ + '', + archs: { + "event,false,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: + ''+ + ''+ + ''+ + ''+ + '', + 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: '' + + '' + + '', + 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: '', + 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: + ''+ + ''+ + ''+ + '', + 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: ``, + 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: '', + 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: + ''+ + ''+ + ''+ + '', + 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: + '', + 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': '', + 'event,false,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: + '', + 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: + ''+ + ''+ + ''+ + '', + 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: + ''+ + ''+ + ''+ + '', + 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: + ` + + + `, + 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: + ` + + + `, + 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: + ''+ + ''+ + ''+ + ''+ + '', + 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: '', + 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: ''+ + ''+ + '', + 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: '', + 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: '', + 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: + ''+ + '', + 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: + ''+ + '', + 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: + ''+ + '', + 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: + ''+ + '', + 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: + '', + 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: + '', + 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: ``, + 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: ` + + `, + 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: ` + + + + `, + 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: ` + + + + '`, + 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: + ``, + 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: + ``, + 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: + ``, + 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: + ` + + + `, + 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 = ` +
+ + + + + + +
`; + 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 = ` +
+ ${[...Array(100)].map((_, i) => '').join('')} + `; + 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: '
' + + '
some htmlaa
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '', + 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: '
' + + '' + + '', + 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: '
' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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': '
'+ + ''+ + '' + + '', + + 'partner_type,false,list': ''+ + ''+ + '', + 'product,false,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: '
' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + // 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. + '' + + '' + + '
', + 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: '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: ` +
+ + + +
`, + 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: ` +
+ + + + + + + + + + +
`, + 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: `
+ + + + + + + + + + + + + + +
`, + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + 'this should be invisible' + + '' + + '' + + '' + + '
', + }); + + 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': `
+ + + + + + + + + + + +
`, + 'partner,false,list': '', + 'partner,false,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:'
' + + '' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
', + 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:'
' + + '' + + '' + + '' + + '' + + '
', + 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: ` +
+ + + + + + +
`, + }); + + 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: ` +
+ + + + + + + + + + +
+ `, + }); + + 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: '
', + archs: { + 'partner,false,list': '', + 'partner,false,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: ` +
+ + + + +
`, + }); + + 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: ` +
+ + + + + + +
`, + }); + + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + }); + 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: '
' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + 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:'
' + + '' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
', + 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: '
' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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': '
' + }, + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '
', + 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: '
' + + '' + + '
', + 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: '
' + + '
' + + '' + + '' + + '', + 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: + '
' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '
' + + '' + + '' + + '' + + '
', + 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: '
' + + '
' + + '' + + '
' + + '
', + 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: '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
', + 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: '
' + + '
' + + '' + + '' + + '
' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + "partner,false,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: ` +
+ + + + +
`, + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '
', + 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: '
', + archs: { + "product,false,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: '
' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + "partner,false,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
tags', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '', + 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: '
' + + '' + + '
' + + '
' + + '', + 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: '
' + + '' + + '
', + 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: '
' + + '' + + '
' + + '
' + + '
' + + '
', + }); + 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: '
' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + 'product,false,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: '
' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '', + 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( + '' + ); + 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: '
' + + '
' + + buttons + + '
' + + '
', + 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: '
' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + res_id: 2, + archs: { + 'partner,false,list': '', + 'partner_type,false,list': '', + 'partner,false,form': '
', + 'product,false,list': '', + }, + 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: '
' + + '' + + '' + + '', + }); + 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: '
' + + '' + + '
', + 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: '
' + + '' + + '' + + '', + }); + 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: '
' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '', + 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: '
' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + 'product,false,form': + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 'product,false,list': '' + }, + 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: '
' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + ' Click me !' + + '', + 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: '
' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + // no inline view + '' + // inline view + '' + + '' + + '' + // no view + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + 'partner,false,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: '
' + + '' + + '' + + '' + + '
', + 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: + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: + '
' + + '' + + '' + + // col=5 otherwise the test is ok even without the + // newline code as this will render a DOM + // element in the third column, leaving no place for + // the next field and its label on the same line. + '' + + '' + + '' + + '' + + '' + + '' + + // col=3 otherwise the test is ok even without the + // newline code as this will render a DOM + // element with the o_group_col_6 class, leaving no + // place for the next group on the same line. + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '', + 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:'
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '
', + 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': '
' + + '' + + '' + + '' + + '
', + 'partner,false,search': '', + 'ir.translation,false,list': '' + + '' + + '' + + '' + + '', + 'ir.translation,false,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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + 'product,false,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: '
' + + '
' + + '
' + + '' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
', + 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: '
' + + '
' + + '
' + + '' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
', + 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: '
' + + '
' + + '
' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '
', + archs: { + 'partner,false,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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '
' + + '
', + 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: '
' + + '' + + '', + 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: '
' + + '' + + '', + 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: '
' + + '' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + 'product,false,form': + '
' + + '' + + '' + + '' + + '' + + '' + + '
', + 'product,false,list': '' + }, + 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: '
' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '', + }); + + 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: '
' + + '' + + '', + 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: '
' + + '
' + + '
' + + '
' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + 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: '
' + + '' + + '' + + '', + archs: { + "partner,nope_not_this_one,list": '' + + '' + + '' + + '', + "partner,34,list": '' + + '' + + '' + + '', + }, + 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:'
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + }); + + 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:'
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + }); + + 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: '
' + + '
' + + '' + + '
' + + '
', + 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: '
' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '', + 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: '
' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '', + 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: '
' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + 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.$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: '
' + + ''+ + '', + 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: '
' + + ''+ + '', + 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: '
' + + ''+ + '', + 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: '
' + + '' + + '' + + '', + archs: { + 'partner,false,list': + '' + + '' + + '' + + '', + }, + }); + + 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: '
' + + ''+ + '', + 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: '
', + 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: '
', + 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: ` +
+ + + + +
`, + 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: ` +
+ + + +
`, + 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: `
+ + `, + }); + + 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.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: `
`, + 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: '
', + }); + + 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: `
+ + + + + + + + + + + `, + 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: '' + + '' + + '', + }); + + 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: '' + + '' + + '', + }); + + 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: '' + + '' + + '', + }); + + 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: '' + + '' + + '', + 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: '' + + '' + + '', + }); + + 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: '' + + '' + + '', + }); + + 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: '', + 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: '' + + '' + + '', + 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: ` + + + `, + viewOptions: { + action: { + help: '

This helper should not be displayed in graph views

' + } + }, + }); + + 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: ` + + + `, + viewOptions: { + action: { + help: '

This helper should not be displayed in graph views

' + } + }, + }); + + 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: '' + + '' + + '', + archs: { + 'foo,false,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: ` + + + `, + viewOptions: { + action: { + help: '

This helper should not be displayed in graph views

' + } + }, + }); + + 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: '' + + '' + + '', + }); + + 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: '' + + '' + + '', + }); + + 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: '', + 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: '', + 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: '', + }); + + 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: '' + + '' + + '' + + '', + 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: '' + + '' + + '' + + '', + 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: '' + + '' + + '', + }); + 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: '' + + '' + + '' + + '', + }); + 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: '' + + '' + + '', + 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: '' + + '' + + '', + }); + 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: '' + + '' + + '', + 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': ` + + + `, + 'foo,false,search': ``, + 'foo,false,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._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: '' + + '' + + '' + + '', + }); + + 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: '' + + '' + + '', + }); + + 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: '' + + '' + + '', + }); + + 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: '' + + '' + + '', + }); + + + 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: '' + + '' + + '' + + '', + }); + + 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: '' + + '' + + '', + 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: '' + + '' + + '', + }); + + 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: '', + 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: '', + 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: '', + 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: ``, + }); + + 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: ` + + + `, + }); + + 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: ` + + `, + }); + + 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: ` + + + `, + }); + + 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: ` + + + `, + 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: ` + + + + `, + domain: [['id', '<', 0]], + viewOptions: { + action: { + help: '

click to add a foo

' + } + }, + }); + + 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: ` + + + + `, + viewOptions: { + action: { + help: '

click to add a foo

' + } + }, + }) + + 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: ` + + + + `, + archs: { + 'foo,false,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 = ` + + + +
+ + +
+
+
+
`; + 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 = ` + + + +
+ + +
+
+
+
`; + 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: '' + + '
' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
', + 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: + ` + + +
+ + +
+
+
+
`, + }); + + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,form': '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,false,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: '' + + '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,false,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: '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,false,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: '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,false,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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: '' + + '' + + '
' + + '
' + + '
', + 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: ` + + +
+
+
`, + archs: { + 'partner,some_view_ref,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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '
' + + 'test link' + + '
' + + '
' + + '
' + + '
' + + '
', + 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 `` 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: '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + 'edit' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: ` + + + + +
+
+
+
+ `, + archs: { + 'partner,false,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: '' + + '' + + '
' + + '
', + }); + + 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: '' + + '
' + + '', + }); + + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + groupBy: ['product_id'], + archs: { + 'product,false,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: '' + + '' + + '' + + '
' + + '
' + + '
', + groupBy: ['product_id'], + archs: { + 'product,false,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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: ` + + + + +
+
+
+
`, + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + }); + + 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: ` + + + + +
+
+
+
`, + viewOptions: { + action: { + help: '

click to add a partner

' + } + }, + 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: '' + + '
' + + '' + + '' + + '
' + + '
', + viewOptions: { + action: { + help: '

click to add a partner

' + } + }, + }); + + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + +
+ + +
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + + +
+ + +
+
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` +
+ + +
+
`, + viewOptions: { + action: { + help: '

click to add a partner

' + } + }, + }); + + 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: + '' + + '' + + '' + + '' + + '
' + + '
' + + '
', + }); + + 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: + '' + + '
' + + '' + + '
' + + '
', + 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: + '' + + '
' + + '' + + '' + + '
' + + '
', + }); + + 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: + '' + + '
' + + '' + + '
' + + '
', + }); + + 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: '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
', + }); + + // 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: '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
', + }); + + 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: '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
', + }); + + 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: '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '' + + '
' + + '
', + 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'), + "
Kikou
hello
", + "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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '
' + + '' + + '' + + '' + + '
' + + '
', + }); + + 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: '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
', + }); + + // 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: + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + archs: { + 'partner,some_view_ref,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: + '' + + '' + + '' + + '
' + + '
' + + '
', + 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: '' + + '' + + '
' + + '' + + '
' + + '
', + }); + + // 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: '' + + '' + + '' + + '
' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '
' + + '
', + 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: ` + + +
+ + + +
+
+
`, + }); + + 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: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
', + }; + + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '
' + + '' + + '
' + + '
', + 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: '' + + '
' + + '' + + '' + + '
' + + '
', + }); + + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '
', + 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: '' + + '' + + '', + }); + + 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: '' + + '
' + + '
', + }).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: '' + + '
' + + '
', + 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: '' + + '
' + + '
', + }).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: '' + + '
' + + '
', + }).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: '' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '' + + '
'+ + ''+ + '
'+ + '
' + + '
' + + '
' + + '
', + 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: '' + + '' + + '' + + '
' + + '' + + '
' + + '
', + 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: '' + + '' + + '
' + + '' + + '
' + + '
', + 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: '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + 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: ` + + + +
+
+
+
`, + }); + + 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: ` + + + +
+
+
+
+ `, + }); + + 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: ` + + + +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ` + + + + + + `; + 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: '', + }); + + 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: '', + }); + + 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: '', + }); + + 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: '', + }); + + + 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: '', + }); + + 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: '', + 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: '', + 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: '', + 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: '', + 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: ` + +
' + + '' + + '', + }, + }); + + 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': + '
' + + '' + + '
' + + '
', + }, + }); + + 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': + '' + + '' + + '' + + '', + 'partner,false,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': + '' + + '' + + '' + + '', + 'partner,false,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': + '' + + '' + + '' + + '', + 'partner,false,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: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + res_id: 1, + archs: { + 'partner,false,form': '
' + + '' + + '' + + '', + + 'instrument,false,form': '
'+ + ''+ + '' + + ''+ + ''+ + '' + + '' + + '', + + 'badassery,false,list': ''+ + ''+ + '', + + 'badassery,false,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: '
' + + '' + + '' + + '', + res_id: 1, + archs: { + 'instrument,false,form': '
'+ + ''+ + '' + + '', + + 'badassery,false,list': ''+ + ''+ + '', + }, + 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': + '' + + '' + + '', + 'partner,false,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: '
' + + '' + + '' + + '', + res_id: 1, + archs: { + 'instrument,false,list': ''+ + ''+ + '', + 'instrument,false,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': + '
' + + '' + + '
', + }, + }); + + 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(); + }); + +}); + +}); -- cgit v1.2.3