summaryrefslogtreecommitdiff
path: root/addons/web/static/tests/views
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/tests/views
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/tests/views')
-rw-r--r--addons/web/static/tests/views/abstract_controller_tests.js168
-rw-r--r--addons/web/static/tests/views/abstract_model_tests.js130
-rw-r--r--addons/web/static/tests/views/abstract_view_banner_tests.js108
-rw-r--r--addons/web/static/tests/views/abstract_view_tests.js146
-rw-r--r--addons/web/static/tests/views/basic_model_tests.js2533
-rw-r--r--addons/web/static/tests/views/calendar_tests.js3883
-rw-r--r--addons/web/static/tests/views/form_benchmarks.js108
-rw-r--r--addons/web/static/tests/views/form_tests.js9907
-rw-r--r--addons/web/static/tests/views/graph_tests.js2048
-rw-r--r--addons/web/static/tests/views/kanban_benchmarks.js92
-rw-r--r--addons/web/static/tests/views/kanban_model_tests.js380
-rw-r--r--addons/web/static/tests/views/kanban_tests.js7248
-rw-r--r--addons/web/static/tests/views/list_benchmarks.js113
-rw-r--r--addons/web/static/tests/views/list_tests.js11702
-rw-r--r--addons/web/static/tests/views/pivot_tests.js3294
-rw-r--r--addons/web/static/tests/views/qweb_tests.js72
-rw-r--r--addons/web/static/tests/views/sample_server_tests.js486
-rw-r--r--addons/web/static/tests/views/search_panel_tests.js4173
-rw-r--r--addons/web/static/tests/views/view_dialogs_tests.js615
19 files changed, 47206 insertions, 0 deletions
diff --git a/addons/web/static/tests/views/abstract_controller_tests.js b/addons/web/static/tests/views/abstract_controller_tests.js
new file mode 100644
index 00000000..43b71b52
--- /dev/null
+++ b/addons/web/static/tests/views/abstract_controller_tests.js
@@ -0,0 +1,168 @@
+odoo.define("base.abstract_controller_tests", function (require) {
+"use strict";
+
+const { xml } = owl.tags;
+
+var testUtils = require("web.test_utils");
+var createView = testUtils.createView;
+var BasicView = require("web.BasicView");
+var BasicRenderer = require("web.BasicRenderer");
+const AbstractRenderer = require('web.AbstractRendererOwl');
+const RendererWrapper = require('web.RendererWrapper');
+
+function getHtmlRenderer(html) {
+ return BasicRenderer.extend({
+ start: function () {
+ this.$el.html(html);
+ return this._super.apply(this, arguments);
+ }
+ });
+}
+
+function getOwlView(owlRenderer, viewType) {
+ viewType = viewType || "test";
+ return BasicView.extend({
+ viewType: viewType,
+ config: _.extend({}, BasicView.prototype.config, {
+ Renderer: owlRenderer,
+ }),
+ getRenderer() {
+ return new RendererWrapper(null, this.config.Renderer, {});
+ }
+ });
+}
+
+function getHtmlView(html, viewType) {
+ viewType = viewType || "test";
+ return BasicView.extend({
+ viewType: viewType,
+ config: _.extend({}, BasicView.prototype.config, {
+ Renderer: getHtmlRenderer(html)
+ })
+ });
+}
+
+QUnit.module("Views", {
+ beforeEach: function () {
+ this.data = {
+ test_model: {
+ fields: {},
+ records: []
+ }
+ };
+ }
+}, function () {
+ QUnit.module('AbstractController');
+
+ QUnit.test('click on a a[type="action"] child triggers the correct action', async function (assert) {
+ assert.expect(7);
+
+ var html =
+ "<div>" +
+ '<a name="a1" type="action" class="simple">simple</a>' +
+ '<a name="a2" type="action" class="with-child">' +
+ "<span>child</input>" +
+ "</a>" +
+ '<a type="action" data-model="foo" data-method="bar" class="method">method</a>' +
+ '<a type="action" data-model="foo" data-res-id="42" class="descr">descr</a>' +
+ '<a type="action" data-model="foo" class="descr2">descr2</a>' +
+ "</div>";
+
+ var view = await createView({
+ View: getHtmlView(html, "test"),
+ data: this.data,
+ model: "test_model",
+ arch: "<test/>",
+ intercepts: {
+ do_action: function (event) {
+ assert.step(event.data.action.name || event.data.action);
+ }
+ },
+ mockRPC: function (route, args) {
+ if (args.model === 'foo' && args.method === 'bar') {
+ assert.step("method");
+ return Promise.resolve({name: 'method'});
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+ await testUtils.dom.click(view.$(".simple"));
+ await testUtils.dom.click(view.$(".with-child span"));
+ await testUtils.dom.click(view.$(".method"));
+ await testUtils.dom.click(view.$(".descr"));
+ await testUtils.dom.click(view.$(".descr2"));
+ assert.verifySteps(["a1", "a2", "method", "method", "descr", "descr2"]);
+
+ view.destroy();
+ });
+
+ QUnit.test('OWL Renderer correctly destroyed', async function (assert) {
+ assert.expect(2);
+
+ class Renderer extends AbstractRenderer {
+ __destroy() {
+ assert.step("destroy");
+ super.__destroy();
+ }
+ }
+ Renderer.template = xml`<div>Test</div>`;
+
+ var view = await createView({
+ View: getOwlView(Renderer, "test"),
+ data: this.data,
+ model: "test_model",
+ arch: "<test/>",
+ });
+ view.destroy();
+
+ assert.verifySteps(["destroy"]);
+
+ });
+
+ QUnit.test('Correctly set focus to search panel with Owl Renderer', async function (assert) {
+ assert.expect(1);
+
+ class Renderer extends AbstractRenderer { }
+ Renderer.template = xml`<div>Test</div>`;
+
+ var view = await createView({
+ View: getOwlView(Renderer, "test"),
+ data: this.data,
+ model: "test_model",
+ arch: "<test/>",
+ });
+ assert.hasClass(document.activeElement, "o_searchview_input");
+ view.destroy();
+ });
+
+ QUnit.test('Owl Renderer mounted/willUnmount hooks are properly called', async function (assert) {
+ // This test could be removed as soon as controllers and renderers will
+ // both be converted in Owl.
+ assert.expect(3);
+
+ class Renderer extends AbstractRenderer {
+ mounted() {
+ assert.step("mounted");
+ }
+ willUnmount() {
+ assert.step("unmounted");
+ }
+ }
+ Renderer.template = xml`<div>Test</div>`;
+
+ const view = await createView({
+ View: getOwlView(Renderer, "test"),
+ data: this.data,
+ model: "test_model",
+ arch: "<test/>",
+ });
+
+ view.destroy();
+
+ assert.verifySteps([
+ "mounted",
+ "unmounted",
+ ]);
+ });
+});
+});
diff --git a/addons/web/static/tests/views/abstract_model_tests.js b/addons/web/static/tests/views/abstract_model_tests.js
new file mode 100644
index 00000000..544a908b
--- /dev/null
+++ b/addons/web/static/tests/views/abstract_model_tests.js
@@ -0,0 +1,130 @@
+odoo.define('web.abstract_model_tests', function (require) {
+ "use strict";
+
+ const AbstractModel = require('web.AbstractModel');
+ const Domain = require('web.Domain');
+
+ QUnit.module('Views', {}, function () {
+ QUnit.module('AbstractModel');
+
+ QUnit.test('leave sample mode when unknown route is called on sample server', async function (assert) {
+ assert.expect(4);
+
+ const Model = AbstractModel.extend({
+ _isEmpty() {
+ return true;
+ },
+ async __load() {
+ if (this.isSampleModel) {
+ await this._rpc({ model: 'partner', method: 'unknown' });
+ }
+ },
+ });
+
+ const model = new Model(null, {
+ modelName: 'partner',
+ fields: {},
+ useSampleModel: true,
+ SampleModel: Model,
+ });
+
+ assert.ok(model.useSampleModel);
+ assert.notOk(model._isInSampleMode);
+
+ await model.load({});
+
+ assert.notOk(model.useSampleModel);
+ assert.notOk(model._isInSampleMode);
+
+ model.destroy();
+ });
+
+ QUnit.test("don't cath general error on sample server in sample mode", async function (assert) {
+ assert.expect(5);
+
+ const error = new Error();
+
+ const Model = AbstractModel.extend({
+ _isEmpty() {
+ return true;
+ },
+ async __reload() {
+ if (this.isSampleModel) {
+ await this._rpc({ model: 'partner', method: 'read_group' });
+ }
+ },
+ async _rpc() {
+ throw error;
+ },
+ });
+
+ const model = new Model(null, {
+ modelName: 'partner',
+ fields: {},
+ useSampleModel: true,
+ SampleModel: Model,
+ });
+
+ assert.ok(model.useSampleModel);
+ assert.notOk(model._isInSampleMode);
+
+ await model.load({});
+
+ assert.ok(model.useSampleModel);
+ assert.ok(model._isInSampleMode);
+
+ async function reloadModel() {
+ try {
+ await model.reload();
+ } catch (e) {
+ assert.strictEqual(e, error);
+ }
+ }
+
+ await reloadModel();
+
+ model.destroy();
+ });
+
+ QUnit.test('fetch sample data: concurrency', async function (assert) {
+ assert.expect(3);
+
+ const Model = AbstractModel.extend({
+ _isEmpty() {
+ return true;
+ },
+ __get() {
+ return { isSample: !!this.isSampleModel };
+ },
+ });
+
+ const model = new Model(null, {
+ modelName: 'partner',
+ fields: {},
+ useSampleModel: true,
+ SampleModel: Model,
+ });
+
+ await model.load({ domain: Domain.FALSE_DOMAIN, });
+
+ const beforeReload = model.get(null, { withSampleData: true });
+
+ const reloaded = model.reload(null, { domain: Domain.TRUE_DOMAIN });
+ const duringReload = model.get(null, { withSampleData: true });
+
+ await reloaded;
+
+ const afterReload = model.get(null, { withSampleData: true });
+
+ assert.strictEqual(beforeReload.isSample, true,
+ "Sample data flag must be true before reload"
+ );
+ assert.strictEqual(duringReload.isSample, true,
+ "Sample data flag must be true during reload"
+ );
+ assert.strictEqual(afterReload.isSample, false,
+ "Sample data flag must be true after reload"
+ );
+ });
+ });
+});
diff --git a/addons/web/static/tests/views/abstract_view_banner_tests.js b/addons/web/static/tests/views/abstract_view_banner_tests.js
new file mode 100644
index 00000000..e5aa14fa
--- /dev/null
+++ b/addons/web/static/tests/views/abstract_view_banner_tests.js
@@ -0,0 +1,108 @@
+odoo.define('web.abstract_view_banner_tests', function (require) {
+"use strict";
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var AbstractView = require('web.AbstractView');
+
+var testUtils = require('web.test_utils');
+var createView = testUtils.createView;
+
+var TestRenderer = AbstractRenderer.extend({
+ _renderView: function () {
+ this.$el.addClass('test_content');
+ return this._super();
+ },
+});
+
+var TestView = AbstractView.extend({
+ type: 'test',
+ config: _.extend({}, AbstractView.prototype.config, {
+ Renderer: TestRenderer
+ }),
+});
+
+var test_css_url = '/test_assetsbundle/static/src/css/test_cssfile1.css';
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ test_model: {
+ fields: {},
+ records: [],
+ },
+ };
+ },
+ afterEach: function () {
+ $('head link[href$="' + test_css_url + '"]').remove();
+ }
+ }, function () {
+ QUnit.module('BasicRenderer');
+
+ QUnit.test("The banner should be fetched from the route", function (assert) {
+ var done = assert.async();
+ assert.expect(6);
+
+ var banner_html =`
+ <div class="modal o_onboarding_modal o_technical_modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-footer">
+ <a type="action" class="btn btn-primary" data-dismiss="modal"
+ data-toggle="collapse" href=".o_onboarding_container">
+ Remove
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="o_onboarding_container collapse show">
+ <div class="o_onboarding_wrap">
+ <a href="#" data-toggle="modal" data-target=".o_onboarding_modal"
+ class="float-right o_onboarding_btn_close">
+ <i class="fa fa-times" title="Close the onboarding panel" />
+ </a>
+ </div>
+ <div>
+ <link type="text/css" href="` + test_css_url + `" rel="stylesheet">
+ <div class="hello_banner">Here is the banner</div>
+ </div>
+ </div>`;
+
+ createView({
+ View: TestView,
+ model: 'test_model',
+ data: this.data,
+ arch: '<test banner_route="/module/hello_banner"/>',
+ mockRPC: function (route, args) {
+ if (route === '/module/hello_banner') {
+ assert.step(route);
+ return Promise.resolve({html: banner_html});
+ }
+ return this._super(route, args);
+ },
+ }).then(async function (view) {
+ var $banner = view.$('.hello_banner');
+ assert.strictEqual($banner.length, 1,
+ "The view should contain the response from the controller.");
+ assert.verifySteps(['/module/hello_banner'], "The banner should be fetched.");
+
+ var $head_link = $('head link[href$="' + test_css_url + '"]');
+ assert.strictEqual($head_link.length, 1,
+ "The stylesheet should have been added to head.");
+
+ var $banner_link = $('link[href$="' + test_css_url + '"]', $banner);
+ assert.strictEqual($banner_link.length, 0,
+ "The stylesheet should have been removed from the banner.");
+
+ await testUtils.dom.click(view.$('.o_onboarding_btn_close')); // click on close to remove banner
+ await testUtils.dom.click(view.$('.o_technical_modal .btn-primary:contains("Remove")')); // click on button remove from techinal modal
+ assert.strictEqual(view.$('.o_onboarding_container.show').length, 0,
+ "Banner should be removed from the view");
+
+ view.destroy();
+ done();
+ });
+ });
+ }
+);
+});
diff --git a/addons/web/static/tests/views/abstract_view_tests.js b/addons/web/static/tests/views/abstract_view_tests.js
new file mode 100644
index 00000000..b2e0cee0
--- /dev/null
+++ b/addons/web/static/tests/views/abstract_view_tests.js
@@ -0,0 +1,146 @@
+odoo.define('web.abstract_view_tests', function (require) {
+"use strict";
+
+var AbstractView = require('web.AbstractView');
+var ajax = require('web.ajax');
+var testUtils = require('web.test_utils');
+
+var createActionManager = testUtils.createActionManager;
+var createView = testUtils.createView;
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ fake_model: {
+ fields: {},
+ record: [],
+ },
+ foo: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ bar: {string: "Bar", type: "boolean"},
+ },
+ records: [
+ {id: 1, bar: true, foo: "yop"},
+ {id: 2, bar: true, foo: "blip"},
+ ]
+ },
+ };
+ },
+}, function () {
+
+ QUnit.module('AbstractView');
+
+ QUnit.test('lazy loading of js libs (in parallel)', async function (assert) {
+ var done = assert.async();
+ assert.expect(6);
+
+ var prom = testUtils.makeTestPromise();
+ var loadJS = ajax.loadJS;
+ ajax.loadJS = function (url) {
+ assert.step(url);
+ return prom.then(function () {
+ assert.step(url + ' loaded');
+ });
+ };
+
+ var View = AbstractView.extend({
+ jsLibs: [['a', 'b']],
+ });
+ createView({
+ View: View,
+ arch: '<fake/>',
+ data: this.data,
+ model: 'fake_model',
+ }).then(function (view) {
+ assert.verifySteps(['a loaded', 'b loaded'],
+ "should wait for both libs to be loaded");
+ ajax.loadJS = loadJS;
+ view.destroy();
+ done();
+ });
+
+ await testUtils.nextTick();
+ assert.verifySteps(['a', 'b'], "both libs should be loaded in parallel");
+ prom.resolve();
+ });
+
+ QUnit.test('lazy loading of js libs (sequentially)', async function (assert) {
+ var done = assert.async();
+ assert.expect(10);
+
+ var proms = {
+ a: testUtils.makeTestPromise(),
+ b: testUtils.makeTestPromise(),
+ c: testUtils.makeTestPromise(),
+ };
+ var loadJS = ajax.loadJS;
+ ajax.loadJS = function (url) {
+ assert.step(url);
+ return proms[url].then(function () {
+ assert.step(url + ' loaded');
+ });
+ };
+
+ var View = AbstractView.extend({
+ jsLibs: [
+ ['a', 'b'],
+ 'c',
+ ],
+ });
+ createView({
+ View: View,
+ arch: '<fake/>',
+ data: this.data,
+ model: 'fake_model',
+ }).then(function (view) {
+ assert.verifySteps(['c loaded'], "should wait for all libs to be loaded");
+ ajax.loadJS = loadJS;
+ view.destroy();
+ done();
+ });
+ await testUtils.nextTick();
+ assert.verifySteps(['a', 'b'], "libs 'a' and 'b' should be loaded in parallel");
+ await proms.b.resolve();
+ await testUtils.nextTick();
+ assert.verifySteps(['b loaded'], "should wait for 'a' and 'b' to be loaded before loading 'c'");
+ await proms.a.resolve();
+ await testUtils.nextTick();
+ assert.verifySteps(['a loaded', 'c'], "should load 'c' when 'a' and 'b' are loaded");
+ await proms.c.resolve();
+ });
+
+ QUnit.test('group_by from context can be a string, instead of a list of strings', async function (assert) {
+ assert.expect(1);
+
+ var actionManager = await createActionManager({
+ actions: [{
+ id: 1,
+ name: 'Foo',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list']],
+ context: {
+ group_by: 'bar',
+ },
+ }],
+ archs: {
+ 'foo,false,list': '<tree><field name="foo"/><field name="bar"/></tree>',
+ 'foo,false,search': '<search></search>',
+ },
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.deepEqual(args.kwargs.groupby, ['bar']);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await actionManager.doAction(1);
+
+ actionManager.destroy();
+ });
+
+});
+});
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
new file mode 100644
index 00000000..a97fb0dd
--- /dev/null
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -0,0 +1,2533 @@
+odoo.define('web.basic_model_tests', function (require) {
+ "use strict";
+
+ var BasicModel = require('web.BasicModel');
+ var FormView = require('web.FormView');
+ var testUtils = require('web.test_utils');
+
+ var createModel = testUtils.createModel;
+ var createView = testUtils.createView;
+
+ QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ display_name: { string: "STRING", type: 'char' },
+ // the following 2 fields must remain in that order to check that
+ // active has priority over x_active despite the order
+ x_active: { string: "Custom Active", type: 'boolean', default: true},
+ active: {string: "Active", type: 'boolean', default: true},
+ total: { string: "Total", type: 'integer' },
+ foo: { string: "Foo", type: 'char' },
+ bar: { string: "Bar", type: 'integer' },
+ qux: { string: "Qux", type: 'many2one', relation: 'partner' },
+ product_id: { string: "Favorite product", type: 'many2one', relation: 'product' },
+ product_ids: { string: "Favorite products", type: 'one2many', relation: 'product' },
+ category: { string: "Category M2M", type: 'many2many', relation: 'partner_type' },
+ date: { string: "Date Field", type: 'date' },
+ reference: { string: "Reference Field", type: 'reference', selection: [["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]] },
+ },
+ records: [
+ { id: 1, foo: 'blip', bar: 1, product_id: 37, category: [12], display_name: "first partner", date: "2017-01-25" },
+ { id: 2, foo: 'gnap', bar: 2, product_id: 41, display_name: "second partner" },
+ ],
+ onchanges: {},
+ },
+ product: {
+ fields: {
+ display_name: { string: "Product Display Name", type: "char" },
+ name: { string: "Product Name", type: "char" },
+ category: { string: "Category M2M", type: 'many2many', relation: 'partner_type' },
+ active: {string: "Active", type: 'boolean', default: true},
+ },
+ records: [
+ { id: 37, display_name: "xphone" },
+ { id: 41, display_name: "xpad" }
+ ]
+ },
+ partner_type: {
+ fields: {
+ display_name: { string: "Partner Type", type: "char" },
+ date: { string: "Date Field", type: 'date' },
+ x_active: { string: "Custom Active", type: 'boolean', default: true},
+ },
+ records: [
+ { id: 12, display_name: "gold", date: "2017-01-25" },
+ { id: 14, display_name: "silver" },
+ { id: 15, display_name: "bronze" }
+ ]
+ },
+ partner_title: {
+ fields: {
+ display_name: { string: "Partner Title", type: 'char'},
+ },
+ records: [
+ { id: 42, display_name: "Dr."},
+ ]
+ }
+ };
+
+ // add related fields to category.
+ this.data.partner.fields.category.relatedFields =
+ $.extend(true, {}, this.data.partner_type.fields);
+ this.params = {
+ res_id: 2,
+ modelName: 'partner',
+ fields: this.data.partner.fields,
+ };
+ },
+ }, function () {
+ QUnit.module('BasicModel');
+
+ QUnit.test('can process x2many commands', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.fields.product_ids.default = [[0, 0, { category: [] }]];
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="product_ids"/>
+ </form>
+ `,
+ archs: {
+ 'product,false,list': `
+ <tree>
+ <field name="display_name"/>
+ </tree>
+ `,
+ 'product,false,kanban': `
+ <kanban>
+ <templates><t t-name="kanban-box">
+ <div><field name="display_name"/></div>
+ </t></templates>
+ </kanban>
+ `,
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC(route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps([
+ 'load_views',
+ 'onchange',
+ ]);
+ assert.containsOnce(form, '.o_field_x2many_list', 'should have rendered a x2many list');
+ assert.containsOnce(form, '.o_data_row', 'should have added 1 record as default');
+ assert.containsOnce(form, '.o_field_x2many_list_row_add', 'should have rendered a x2many add row on list');
+ form.destroy();
+ });
+
+ QUnit.test('can process x2many commands (with multiple fields)', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.product_ids.default = [[0, 0, { category: [] }]];
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="product_ids"/>
+ </form>
+ `,
+ archs: {
+ 'product,false,list': `
+ <tree>
+ <field name="display_name"/>
+ <field name="active"/>
+ </tree>
+ `,
+ },
+ mockRPC(route, args) {
+ if (args.method === "create") {
+ const product_ids = args.args[0].product_ids;
+ const values = product_ids[0][2];
+ assert.strictEqual(values.active, true, "active field should be set");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickSave(form);
+ form.destroy();
+ });
+
+ QUnit.test('can load a record', async function (assert) {
+ assert.expect(7);
+
+ this.params.fieldNames = ['foo'];
+ this.params.context = { active_field: 2 };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.deepEqual(args.kwargs.context, {
+ active_field: 2,
+ bin_size: true,
+ someKey: 'some value',
+ }, "should have sent the correct context");
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_context: { someKey: 'some value' },
+ }
+ });
+
+ assert.strictEqual(model.get(1), null, "should return null for non existing key");
+
+ var resultID = await model.load(this.params);
+ // it is a string, because it is used as a key in an object
+ assert.strictEqual(typeof resultID, 'string', "result should be a valid id");
+
+ var record = model.get(resultID);
+ assert.strictEqual(record.res_id, 2, "res_id read should be the same as asked");
+ assert.strictEqual(record.type, 'record', "should be of type 'record'");
+ assert.strictEqual(record.data.foo, "gnap", "should correctly read value");
+ assert.strictEqual(record.data.bar, undefined, "should not fetch the field 'bar'");
+ model.destroy();
+ });
+
+ QUnit.test('rejects loading a record with invalid id', async function (assert) {
+ assert.expect(1);
+
+ this.params.res_id = 99;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+ try {
+ await model.load(this.params);
+ }
+ catch (e) {
+ assert.ok("load should return a rejected deferred for an invalid id");
+ }
+
+ model.destroy();
+ });
+
+ QUnit.test('notify change with many2one', async function (assert) {
+ assert.expect(2);
+
+ this.params.fieldNames = ['foo', 'qux'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.qux, false, "qux field should be false");
+ await model.notifyChanges(resultID, { qux: { id: 1, display_name: "hello" } });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.qux.data.id, 1, "qux field should be 1");
+ model.destroy();
+ });
+
+ QUnit.test('notify change on many2one: unset and reset same value', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[1].qux = 1;
+
+ this.params.fieldNames = ['qux'];
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.qux.data.id, 1, "qux value should be 1");
+
+ await model.notifyChanges(resultID, { qux: false });
+ record = model.get(resultID);
+ assert.strictEqual(record.data.qux, false, "qux should be unset");
+
+ await model.notifyChanges(resultID, { qux: { id: 1, display_name: 'second_partner' } });
+ record = model.get(resultID);
+ assert.strictEqual(record.data.qux.data.id, 1, "qux value should be 1 again");
+ model.destroy();
+ });
+
+ QUnit.test('write on a many2one', async function (assert) {
+ assert.expect(4);
+ var self = this;
+
+ this.params.fieldNames = ['product_id'];
+
+ var rpcCount = 0;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_id.data.display_name, 'xpad',
+ "should be initialized with correct value");
+
+ await model.notifyChanges(resultID, { product_id: { id: 37, display_name: 'xphone' } });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_id.data.display_name, 'xphone',
+ "should be changed with correct value");
+
+ await model.save(resultID);
+
+ assert.strictEqual(self.data.partner.records[1].product_id, 37,
+ "should have really saved the data");
+ assert.strictEqual(rpcCount, 3, "should have done 3 rpc: 1 read, 1 write, 1 read");
+ model.destroy();
+ });
+
+ QUnit.test('basic onchange', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.foo.onChange = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.bar = obj.foo.length;
+ };
+
+ this.params.fieldNames = ['foo', 'bar'];
+ this.params.context = { hello: 'world' };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ var context = args.kwargs.context;
+ assert.deepEqual(context, { hello: 'world' },
+ "context should be sent by the onchange");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'gnap', "foo field is properly initialized");
+ assert.strictEqual(record.data.bar, 2, "bar field is properly initialized");
+
+ await model.notifyChanges(resultID, { foo: 'mary poppins' });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'mary poppins', "onchange has been applied");
+ assert.strictEqual(record.data.bar, 12, "onchange has been applied");
+ model.destroy();
+ });
+
+ QUnit.test('onchange with a many2one', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.product_id.onChange = true;
+ this.data.partner.onchanges.product_id = function (obj) {
+ if (obj.product_id === 37) {
+ obj.foo = "space lollipop";
+ }
+ };
+
+ this.params.fieldNames = ['foo', 'product_id'];
+
+ var rpcCount = 0;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.strictEqual(args.args[2], "product_id",
+ "should send the only changed field as a string, not a list");
+ }
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'gnap', "foo field is properly initialized");
+ assert.strictEqual(record.data.product_id.data.id, 41, "product_id field is properly initialized");
+
+ await model.notifyChanges(resultID, { product_id: { id: 37, display_name: 'xphone' } });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'space lollipop', "onchange has been applied");
+ assert.strictEqual(rpcCount, 2, "should have done 2 rpc: 1 read and 1 onchange");
+ model.destroy();
+ });
+
+ QUnit.test('onchange on a one2many not in view (fieldNames)', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.fields.foo.onChange = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.bar = obj.foo.length;
+ obj.product_ids = [];
+ };
+
+ this.params.fieldNames = ['foo'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'gnap', "foo field is properly initialized");
+ assert.strictEqual(record.data.bar, undefined, "bar field is not loaded");
+ assert.strictEqual(record.data.product_ids, undefined, "product_ids field is not loaded");
+
+ await model.notifyChanges(resultID, { foo: 'mary poppins' });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'mary poppins', "onchange has been applied");
+ assert.strictEqual(record.data.bar, 12, "onchange has been applied");
+ assert.strictEqual(record.data.product_ids, undefined,
+ "onchange on product_ids (one2many) has not been applied");
+ model.destroy();
+ });
+
+ QUnit.test('notifyChange on a one2many', async function (assert) {
+ assert.expect(9);
+
+ this.data.partner.records[1].product_ids = [37];
+ this.params.fieldNames = ['product_ids'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ assert.strictEqual(args.model, 'product');
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var o2mParams = {
+ modelName: 'product',
+ fields: this.data.product.fields,
+ fieldNames: ['display_name'],
+ };
+ var resultID = await model.load(this.params);
+ var newRecordID = await model.load(o2mParams);
+ var record = model.get(resultID);
+ var x2mListID = record.data.product_ids.id;
+
+ assert.strictEqual(record.data.product_ids.count, 1,
+ "there should be one record in the relation");
+
+ // trigger a 'ADD' command
+ await model.notifyChanges(resultID, { product_ids: { operation: 'ADD', id: newRecordID } });
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'ADD', id: newRecordID,
+ }], "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 2,
+ "there should be two records in the relation");
+
+ // trigger a 'UPDATE' command
+ await model.notifyChanges(resultID, { product_ids: { operation: 'UPDATE', id: newRecordID } });
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'ADD', id: newRecordID,
+ }, {
+ operation: 'UPDATE', id: newRecordID,
+ }], "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 2,
+ "there should be two records in the relation");
+
+ // trigger a 'DELETE' command on the existing record
+ var existingRecordID = record.data.product_ids.data[0].id;
+ await model.notifyChanges(resultID, { product_ids: { operation: 'DELETE', ids: [existingRecordID] } });
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'ADD', id: newRecordID,
+ }, {
+ operation: 'UPDATE', id: newRecordID,
+ }, {
+ operation: 'DELETE', id: existingRecordID,
+ }],
+ "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 1,
+ "there should be one record in the relation");
+
+ // trigger a 'DELETE' command on the new record
+ await model.notifyChanges(resultID, { product_ids: { operation: 'DELETE', ids: [newRecordID] } });
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'DELETE', id: existingRecordID,
+ }], "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 0,
+ "there should be no record in the relation");
+
+ model.destroy();
+ });
+
+ QUnit.test('notifyChange on a many2one, without display_name', async function (assert) {
+ assert.expect(3);
+
+ this.params.fieldNames = ['product_id'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ assert.strictEqual(args.model, 'product');
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_id.data.display_name, 'xpad',
+ "product_id field is set to xpad");
+
+ await model.notifyChanges(resultID, { product_id: { id: 37 } });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_id.data.display_name, 'xphone',
+ "display_name should have been fetched");
+ model.destroy();
+ });
+
+ QUnit.test('onchange on a char with an unchanged many2one', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.foo.onChange = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.foo = obj.foo + " alligator";
+ };
+
+ this.params.fieldNames = ['foo', 'product_id'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.strictEqual(args.args[1].product_id, 41, "should send correct value");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ await model.notifyChanges(resultID, { foo: 'cookie' });
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'cookie alligator', "onchange has been applied");
+ model.destroy();
+ });
+
+ QUnit.test('onchange on a char with another many2one not set to a value', async function (assert) {
+ assert.expect(2);
+ this.data.partner.records[0].product_id = false;
+ this.data.partner.fields.foo.onChange = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.foo = obj.foo + " alligator";
+ };
+
+ this.params.fieldNames = ['foo', 'product_id'];
+ this.params.res_id = 1;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_id, false, "product_id is not set");
+
+ await model.notifyChanges(resultID, { foo: 'cookie' });
+ record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'cookie alligator', "onchange has been applied");
+ model.destroy();
+ });
+
+ QUnit.test('can get a many2many', async function (assert) {
+ assert.expect(3);
+
+ this.params.res_id = 1;
+ this.params.fieldsInfo = {
+ default: {
+ category: {
+ fieldsInfo: { default: { display_name: {} } },
+ relatedFields: { display_name: { type: "char" } },
+ viewType: 'default',
+ },
+ },
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.category.data[0].res_id, 12,
+ "should have loaded many2many res_ids");
+ assert.strictEqual(record.data.category.data[0].data.display_name, "gold",
+ "should have loaded many2many display_name");
+ record = model.get(resultID, { raw: true });
+ assert.deepEqual(record.data.category, [12],
+ "with option raw, category should only return ids");
+ model.destroy();
+ });
+
+ QUnit.test('can use command add and get many2many value with date field', async function (assert) {
+ assert.expect(2);
+
+ this.params.fieldsInfo = {
+ default: {
+ category: {
+ fieldsInfo: { default: { date: {} } },
+ relatedFields: { date: { type: "date" } },
+ viewType: 'default',
+ },
+ },
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var changes = {
+ category: { operation: 'ADD_M2M', ids: [{ id: 12 }] }
+ };
+ await model.notifyChanges(resultID, changes);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.category.data.length, 1, "should have added one category");
+ assert.strictEqual(record.data.category.data[0].data.date instanceof moment,
+ true, "should have a date parsed in a moment object");
+ model.destroy();
+ });
+
+ QUnit.test('many2many with ADD_M2M command and context with parent key', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner_type.fields.some_char = { type: "char" };
+ this.params.fieldsInfo = {
+ default: {
+ category: {
+ fieldsInfo: { default: { some_char: { context: "{'a': parent.foo}" } } },
+ relatedFields: { some_char: { type: "char" } },
+ viewType: 'default',
+ },
+ foo: {},
+ },
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var changes = {
+ category: { operation: 'ADD_M2M', ids: [{ id: 12 }] }
+ };
+ await model.notifyChanges(resultID, changes);
+ var record = model.get(resultID);
+ var categoryRecord = record.data.category.data[0];
+ assert.deepEqual(categoryRecord.getContext({ fieldName: 'some_char' }), { a: 'gnap' },
+ "should properly evaluate context");
+ model.destroy();
+ });
+
+ QUnit.test('can fetch a list', async function (assert) {
+ assert.expect(4);
+
+ this.params.fieldNames = ['foo'];
+ this.params.domain = [];
+ this.params.groupedBy = [];
+ this.params.res_id = undefined;
+ this.params.context = { active_field: 2 };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.context.active_field, 2,
+ "should have sent the correct context");
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+
+ assert.strictEqual(record.type, 'list', "record fetched should be a list");
+ assert.strictEqual(record.data.length, 2, "should have fetched 2 records");
+ assert.strictEqual(record.data[0].data.foo, 'blip', "first record should have 'blip' in foo field");
+ model.destroy();
+ });
+
+ QUnit.test('fetch x2manys in list, with not too many rpcs', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].category = [12, 15];
+ this.data.partner.records[1].category = [12, 14];
+
+ this.params.fieldNames = ['category'];
+ this.params.domain = [];
+ this.params.groupedBy = [];
+ this.params.res_id = undefined;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(route);
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+
+ assert.strictEqual(record.data[0].data.category.data.length, 2,
+ "first record should have 2 categories loaded");
+ assert.verifySteps(["/web/dataset/search_read"],
+ "should have done 2 rpc (searchread and read category)");
+ model.destroy();
+ });
+
+ QUnit.test('can make a default_record with the help of onchange', async function (assert) {
+ assert.expect(5);
+
+ this.params.context = {};
+ this.params.fieldNames = ['product_id', 'category', 'product_ids'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_id, false, "m2o default value should be false");
+ assert.deepEqual(record.data.product_ids.data, [], "o2m default should be []");
+ assert.deepEqual(record.data.category.data, [], "m2m default should be []");
+
+ assert.verifySteps(['onchange']);
+
+ model.destroy();
+ });
+
+ QUnit.test('default_get returning a non requested field', async function (assert) {
+ // 'default_get' returns a default value for the fields given in
+ // arguments. It should not return a value for fields that have not be
+ // requested. However, it happens (e.g. res.users), and the webclient
+ // should not crash when this situation occurs (the field should simply
+ // be ignored).
+ assert.expect(2);
+
+ this.params.context = {};
+ this.params.fieldNames = ['category'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super(route, args);
+ if (args.method === 'default_get') {
+ result.product_ids = [[6, 0, [37, 41]]];
+ }
+ return result;
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.ok('category' in record.data,
+ "should have processed 'category'");
+ assert.notOk('product_ids' in record.data,
+ "should have ignored 'product_ids'");
+
+ model.destroy();
+ });
+
+ QUnit.test('can make a default_record with default relational values', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.fields.product_id.default = 37;
+ this.data.partner.fields.product_ids.default = [
+ [0, false, { name: 'xmac' }],
+ [0, false, { name: 'xcloud' }]
+ ];
+ this.data.partner.fields.category.default = [
+ [6, false, [12, 14]]
+ ];
+
+ this.params.fieldNames = ['product_id', 'category', 'product_ids'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+ this.params.fieldsInfo = {
+ form: {
+ category: {},
+ product_id: {},
+ product_ids: {
+ fieldsInfo: {
+ default: { name: {} },
+ },
+ relatedFields: this.data.product.fields,
+ viewType: 'default',
+ },
+ },
+ };
+ this.params.viewType = 'form';
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.deepEqual(record.data.product_id.data.display_name, 'xphone',
+ "m2o default should be xphone");
+ assert.deepEqual(record.data.product_ids.data.length,
+ 2, "o2m default should have two records");
+ assert.deepEqual(record.data.product_ids.data[0].data.name,
+ 'xmac', "first o2m default value should be xmac");
+ assert.deepEqual(record.data.category.res_ids, [12, 14],
+ "m2m default should be [12, 14]");
+
+ assert.verifySteps(['onchange']);
+
+ model.destroy();
+ });
+
+ QUnit.test('default_record, with onchange on many2one', async function (assert) {
+ assert.expect(1);
+
+ // the onchange is done by the mockRPC because we want to return a value
+ // of 'false', which does not work with the mockserver mockOnChange method.
+ this.data.partner.onchanges.product_id = true;
+
+ this.params.context = {};
+ this.params.fieldNames = ['product_id'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({ value: { product_id: false } });
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_id, false, "m2o default value should be false");
+ model.destroy();
+ });
+
+ QUnit.test('default record: batch namegets on same model and res_id', async function (assert) {
+ assert.expect(3);
+
+ var rpcCount = 0;
+ var fields = this.data.partner.fields;
+ fields.other_product_id = _.extend({}, fields.product_id);
+ fields.product_id.default = 37;
+ fields.other_product_id.default = 41;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+
+ var params = {
+ context: {},
+ fieldNames: ['other_product_id', 'product_id'],
+ fields: fields,
+ modelName: 'partner',
+ type: 'record',
+ };
+
+ var resultID = await model.load(params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_id.data.display_name, "xphone",
+ "should have fetched correct name");
+ assert.strictEqual(record.data.other_product_id.data.display_name, "xpad",
+ "should have fetched correct name");
+ assert.strictEqual(rpcCount, 1, "should have done 1 rpc: onchange");
+ model.destroy();
+ });
+
+ QUnit.test('undoing a change keeps the record dirty', async function (assert) {
+ assert.expect(4);
+
+ this.params.fieldNames = ['foo'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, "gnap", "foo field should properly be set");
+ assert.ok(!model.isDirty(resultID), "record should not be dirty");
+ await model.notifyChanges(resultID, { foo: "hello" });
+ assert.ok(model.isDirty(resultID), "record should be dirty");
+ await model.notifyChanges(resultID, { foo: "gnap" });
+ assert.ok(model.isDirty(resultID), "record should be dirty");
+ model.destroy();
+ });
+
+ QUnit.test('isDirty works correctly on list made empty', async function (assert) {
+ assert.expect(3);
+
+ this.params.fieldNames = ['category'];
+ this.params.res_id = 1;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ var category_value = record.data.category;
+ assert.ok(_.isObject(category_value), "category field should have been fetched");
+ assert.strictEqual(category_value.data.length, 1, "category field should contain one record");
+ await model.notifyChanges(resultID, {
+ category: {
+ operation: 'DELETE',
+ ids: [category_value.data[0].id],
+ }
+ });
+ assert.ok(model.isDirty(resultID), "record should be considered dirty");
+ model.destroy();
+ });
+
+ QUnit.test('can duplicate a record', async function (assert) {
+ assert.expect(4);
+
+ this.params.fieldNames = ['foo'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.display_name, "second partner",
+ "record should have correct display name");
+ assert.strictEqual(record.data.foo, "gnap", "foo should be set to correct value");
+ var duplicateID = await model.duplicateRecord(resultID);
+ var duplicate = model.get(duplicateID);
+ assert.strictEqual(duplicate.data.display_name, "second partner (copy)",
+ "record should have been duplicated");
+ assert.strictEqual(duplicate.data.foo, "gnap", "foo should be set to correct value");
+ model.destroy();
+ });
+
+ QUnit.test('record with many2one set to some value, then set it to none', async function (assert) {
+ assert.expect(3);
+
+ this.params.fieldNames = ['product_id'];
+
+ var self = this;
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_id.data.display_name, 'xpad', "product_id should be set");
+ await model.notifyChanges(resultID, { product_id: false });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_id, false, "product_id should not be set");
+
+ await model.save(resultID);
+
+ assert.strictEqual(self.data.partner.records[1].product_id, false,
+ "should have saved the new product_id value");
+ model.destroy();
+ });
+
+ QUnit.test('internal state of groups remains when reloading', async function (assert) {
+ assert.expect(10);
+
+ this.params.fieldNames = ['foo'];
+ this.params.domain = [];
+ this.params.limit = 80;
+ this.params.groupedBy = ['product_id'];
+ this.params.res_id = undefined;
+
+ var filterEnabled = false;
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group' && filterEnabled) {
+ // as this is not yet supported by the MockServer, simulates
+ // a read_group that returns empty groups
+ // this is the case for several models (e.g. project.task
+ // grouped by stage_id)
+ return this._super.apply(this, arguments).then(function (result) {
+ // artificially filter out records of first group
+ result.groups[0].product_id_count = 0;
+ return result;
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.length, 2, "should have 2 groups");
+ var groupID = record.data[0].id;
+ assert.strictEqual(model.localData[groupID].parentID, resultID,
+ "parentID should be correctly set on groups");
+
+ await model.toggleGroup(groupID);
+
+ record = model.get(resultID);
+ assert.ok(record.data[0].isOpen, "first group should be open");
+ assert.strictEqual(record.data[0].data.length, 1,
+ "first group should have one record");
+ assert.strictEqual(record.data[0].limit, 80,
+ "limit should be 80 by default");
+
+ // change the limit and offset of the first group
+ model.localData[record.data[0].id].limit = 10;
+
+ await model.reload(resultID);
+ record = model.get(resultID);
+ assert.ok(record.data[0].isOpen, "first group should still be open");
+ assert.strictEqual(record.data[0].data.length, 1,
+ "first group should still have one record");
+ assert.strictEqual(record.data[0].limit, 10,
+ "new limit should have been kept");
+
+ // filter some records out: the open group should stay open but now
+ // be empty
+ filterEnabled = true;
+ await model.reload(resultID);
+ record = model.get(resultID);
+ assert.strictEqual(record.data[0].count, 0,
+ "first group's count should be 0");
+ assert.strictEqual(record.data[0].data.length, 0,
+ "first group's data should be empty'");
+ model.destroy();
+ });
+
+ QUnit.test('group on date field with magic grouping method', async function (assert) {
+ assert.expect(1);
+
+ this.params.fieldNames = ['foo'];
+ this.params.groupedBy = ['date:month'];
+ this.params.res_id = undefined;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.deepEqual(args.kwargs.fields, ['foo', 'date'],
+ "should have correctly trimmed the magic grouping info from the field name");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await model.load(this.params);
+ model.destroy();
+ });
+
+
+ QUnit.test('read group when grouped by a selection field', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.selection = {
+ type: 'selection',
+ selection: [['a', 'A'], ['b', 'B']],
+ };
+ this.data.partner.records[0].selection = 'a';
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+ var params = {
+ modelName: 'partner',
+ fields: this.data.partner.fields,
+ fieldNames: ['foo'],
+ groupedBy: ['selection'],
+ };
+
+ var resultID = await model.load(params);
+ var dataPoint = model.get(resultID);
+ assert.strictEqual(dataPoint.data.length, 2, "should have two groups");
+
+ var groupFalse = _.findWhere(dataPoint.data, { value: false });
+ assert.ok(groupFalse, "should have a group for value false");
+ assert.deepEqual(groupFalse.domain, [['selection', '=', false]],
+ "group's domain should be correct");
+
+ var groupA = _.findWhere(dataPoint.data, { value: 'A' });
+ assert.ok(groupA, "should have a group for value 'a'");
+ assert.deepEqual(groupA.domain, [['selection', '=', 'a']],
+ "group's domain should be correct");
+ model.destroy();
+ });
+
+ QUnit.test('create record, then save', async function (assert) {
+ assert.expect(5);
+
+ this.params.fieldNames = ['product_ids'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+ this.params.context = { active_field: 2 };
+
+ var id;
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ // has to be done before the call to _super
+ assert.deepEqual(args.args[0].product_ids, [], "should not have any command");
+ assert.notOk('category' in args.args[0], "should not have other fields");
+
+ assert.strictEqual(args.kwargs.context.active_field, 2,
+ "record's context should be correctly passed");
+ }
+ var result = this._super(route, args);
+ if (args.method === 'create') {
+ result.then(function (res) {
+ id = res;
+ });
+ }
+ return result;
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ await model.save(record.id, { reload: false });
+ record = model.get(resultID);
+ assert.strictEqual(record.res_id, id, "should have correct id from server");
+ assert.strictEqual(record.data.id, id, "should have correct id from server");
+ model.destroy();
+ });
+
+ QUnit.test('write commands on a one2many', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[1].product_ids = [37];
+
+ this.params.fieldNames = ['product_ids'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[0], [2], "should write on res_id = 2");
+ var commands = args.args[1].product_ids;
+ assert.deepEqual(commands[0], [4, 37, false], "first command should be a 4");
+ // TO DO: uncomment next line
+ // assert.strictEqual(commands[1], [0, false, {name: "toy"}], "second command should be a 0");
+ assert.strictEqual(commands[1][0], 0, "second command should be a 0");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID, { raw: true });
+ assert.deepEqual(record.data.product_ids, [37], "should have correct initial value");
+
+ var relatedRecordID = await model.makeRecord('product', [{
+ name: 'name',
+ string: "Product Name",
+ type: "char",
+ value: "xpod"
+ }
+ ]);
+ await model.notifyChanges(record.id, {
+ product_ids: { operation: "ADD", id: relatedRecordID }
+ });
+ await model.save(record.id);
+ model.destroy();
+ });
+
+ QUnit.test('create commands on a one2many', async function (assert) {
+ assert.expect(3);
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ return this._super(route, args);
+ },
+ });
+
+ this.params.fieldsInfo = {
+ default: {
+ product_ids: {
+ fieldsInfo: {
+ default: {
+ display_name: { type: 'string' },
+ }
+ },
+ viewType: 'default',
+ }
+ }
+ };
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.data.length, 0,
+ "one2many should start with a list of length 0");
+
+ await model.notifyChanges(record.id, {
+ product_ids: {
+ operation: "CREATE",
+ data: {
+ display_name: 'coucou',
+ },
+ },
+ });
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.data.length, 1,
+ "one2many should be a list of length 1");
+ assert.strictEqual(record.data.product_ids.data[0].data.display_name, "coucou",
+ "one2many should have correct data");
+ model.destroy();
+ });
+
+ QUnit.test('onchange with a one2many on a new record', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.fields.total.default = 50;
+ this.data.partner.fields.product_ids.onChange = true;
+ this.data.partner.onchanges.product_ids = function (obj) {
+ obj.total += 100;
+ };
+
+ this.params.fieldNames = ['total', 'product_ids'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+ this.params.fieldsInfo = {
+ form: {
+ product_ids: {
+ fieldsInfo: {
+ default: { name: {} },
+ },
+ relatedFields: this.data.product.fields,
+ viewType: 'default',
+ },
+ total: {},
+ },
+ };
+ this.params.viewType = 'form';
+
+ var o2mRecordParams = {
+ fields: this.data.product.fields,
+ fieldNames: ['name'],
+ modelName: 'product',
+ type: 'record',
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange' && args.args[1].total === 150) {
+ assert.deepEqual(args.args[1].product_ids, [[0, args.args[1].product_ids[0][1], { name: "xpod" }]],
+ "Should have sent the create command in the onchange");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.data.length, 0,
+ "one2many should start with a list of length 0");
+
+ // make a default record for the related model
+ var relatedRecordID = await model.load(o2mRecordParams);
+ // update the subrecord
+ await model.notifyChanges(relatedRecordID, { name: 'xpod' });
+ // add the subrecord to the o2m of the main record
+ await model.notifyChanges(resultID, {
+ product_ids: { operation: "ADD", id: relatedRecordID }
+ });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.data.length, 1,
+ "one2many should be a list of length 1");
+ assert.strictEqual(record.data.product_ids.data[0].data.name, "xpod",
+ "one2many should have correct data");
+ model.destroy();
+ });
+
+ QUnit.test('dates are properly loaded and parsed (record)', async function (assert) {
+ assert.expect(2);
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var params = {
+ fieldNames: ['date'],
+ fields: this.data.partner.fields,
+ modelName: 'partner',
+ res_id: 1,
+ };
+
+ await model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.ok(record.data.date instanceof moment,
+ "fetched date field should have been formatted");
+ });
+
+ params.res_id = 2;
+
+ await model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.date, false,
+ "unset date field should be false");
+ });
+ model.destroy();
+ });
+
+ QUnit.test('dates are properly loaded and parsed (list)', async function (assert) {
+ assert.expect(2);
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var params = {
+ fieldNames: ['date'],
+ fields: this.data.partner.fields,
+ modelName: 'partner',
+ type: 'list',
+ };
+
+ await model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ var firstRecord = record.data[0];
+ var secondRecord = record.data[1];
+ assert.ok(firstRecord.data.date instanceof moment,
+ "fetched date field should have been formatted");
+ assert.strictEqual(secondRecord.data.date, false,
+ "if date is not set, it should be false");
+ });
+ model.destroy();
+ });
+
+ QUnit.test('dates are properly loaded and parsed (default_get)', async function (assert) {
+ assert.expect(1);
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var params = {
+ fieldNames: ['date'],
+ fields: this.data.partner.fields,
+ modelName: 'partner',
+ type: 'record',
+ };
+
+ await model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.date, false, "date default value should be false");
+ });
+ model.destroy();
+ });
+
+ QUnit.test('default_get on x2many may return a list of ids', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.category.default = [12, 14];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var params = {
+ fieldNames: ['category'],
+ fields: this.data.partner.fields,
+ modelName: 'partner',
+ type: 'record',
+ };
+
+ await model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.ok(_.isEqual(record.data.category.res_ids, [12, 14]),
+ "category field should have correct default value");
+ });
+
+ model.destroy();
+ });
+
+ QUnit.test('default_get: fetch many2one with default (empty & not) inside x2manys', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.category_m2o = {
+ type: 'many2one',
+ relation: 'partner_type',
+ };
+ this.data.partner.fields.o2m = {
+ string: "O2M", type: 'one2many', relation: 'partner', default: [
+ [6, 0, []],
+ [0, 0, { category_m2o: false, o2m: [] }],
+ [0, 0, { category_m2o: 12, o2m: [] }],
+ ],
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var params = {
+ fieldNames: ['o2m'],
+ fields: this.data.partner.fields,
+ fieldsInfo: {
+ form: {
+ o2m: {
+ relatedFields: this.data.partner.fields,
+ fieldsInfo: {
+ list: {
+ category_m2o: {
+ relatedFields: { display_name: {} },
+ },
+ },
+ },
+ viewType: 'list',
+ },
+ },
+ },
+ modelName: 'partner',
+ type: 'record',
+ viewType: 'form',
+ };
+
+ var resultID = await model.load(params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.o2m.count, 2, "o2m field should contain 2 records");
+ assert.strictEqual(record.data.o2m.data[0].data.category_m2o, false,
+ "first category field should be empty");
+ assert.strictEqual(record.data.o2m.data[1].data.category_m2o.data.display_name, "gold",
+ "second category field should have been correctly fetched");
+
+ model.destroy();
+ });
+
+ QUnit.test('default_get: fetch x2manys inside x2manys', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.o2m = {
+ string: "O2M", type: 'one2many', relation: 'partner', default: [[6, 0, [1]]],
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var params = {
+ fieldNames: ['o2m'],
+ fields: this.data.partner.fields,
+ fieldsInfo: {
+ form: {
+ o2m: {
+ relatedFields: this.data.partner.fields,
+ fieldsInfo: {
+ list: {
+ category: {
+ relatedFields: { display_name: {} },
+ },
+ },
+ },
+ viewType: 'list',
+ },
+ },
+ },
+ modelName: 'partner',
+ type: 'record',
+ viewType: 'form',
+ };
+
+ var resultID = await model.load(params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.o2m.count, 1, "o2m field should contain 1 record");
+ var categoryList = record.data.o2m.data[0].data.category;
+ assert.strictEqual(categoryList.count, 1,
+ "category field should contain 1 record");
+ assert.strictEqual(categoryList.data[0].data.display_name,
+ 'gold', "category records should have been fetched");
+
+ model.destroy();
+ });
+
+ QUnit.test('contexts and domains can be properly fetched', async function (assert) {
+ assert.expect(8);
+
+ this.data.partner.fields.product_id.context = "{'hello': 'world', 'test': foo}";
+ this.data.partner.fields.product_id.domain = "[['hello', 'like', 'world'], ['test', 'like', foo]]";
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ this.params.fieldNames = ['product_id', 'foo'];
+
+ var resultID = await model.load(this.params);
+ var recordPartner = model.get(resultID);
+ assert.strictEqual(typeof recordPartner.getContext, "function",
+ "partner record should have a getContext function");
+ assert.strictEqual(typeof recordPartner.getDomain, "function",
+ "partner record should have a getDomain function");
+ assert.deepEqual(recordPartner.getContext(), {},
+ "asking for a context without a field name should fetch the session/user/view context");
+ assert.deepEqual(recordPartner.getDomain(), [],
+ "asking for a domain without a field name should fetch the session/user/view domain");
+ assert.deepEqual(
+ recordPartner.getContext({ fieldName: "product_id" }),
+ { hello: "world", test: "gnap" },
+ "asking for a context with a field name should fetch the field context (evaluated)");
+ assert.deepEqual(
+ recordPartner.getDomain({ fieldName: "product_id" }),
+ [["hello", "like", "world"], ["test", "like", "gnap"]],
+ "asking for a domain with a field name should fetch the field domain (evaluated)");
+ model.destroy();
+
+ // Try again with xml override of field domain and context
+ model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ this.params.fieldsInfo = {
+ default: {
+ foo: {},
+ product_id: {
+ context: "{'hello2': 'world', 'test2': foo}",
+ domain: "[['hello2', 'like', 'world'], ['test2', 'like', foo]]",
+ },
+ }
+ };
+
+ resultID = await model.load(this.params);
+ recordPartner = model.get(resultID);
+ assert.deepEqual(
+ recordPartner.getContext({ fieldName: "product_id" }),
+ { hello2: "world", test2: "gnap" },
+ "field context should have been overridden by xml attribute");
+ assert.deepEqual(
+ recordPartner.getDomain({ fieldName: "product_id" }),
+ [["hello2", "like", "world"], ["test2", "like", "gnap"]],
+ "field domain should have been overridden by xml attribute");
+ model.destroy();
+ });
+
+ QUnit.test('dont write on readonly fields (write and create)', async function (assert) {
+ assert.expect(6);
+
+ this.params.fieldNames = ['foo', 'bar'];
+ this.data.partner.fields.foo.onChange = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.bar = obj.foo.length;
+ };
+ this.params.fieldsInfo = {
+ default: {
+ foo: {},
+ bar: {
+ modifiers: {
+ readonly: true,
+ },
+ },
+ }
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], { foo: "verylongstring" },
+ "should only save foo field");
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], { foo: "anotherverylongstring" },
+ "should only save foo field");
+ }
+ return this._super(route, args);
+ },
+ });
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 2,
+ "should be initialized with correct value");
+
+ await model.notifyChanges(resultID, { foo: "verylongstring" });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 14,
+ "should be changed with correct value");
+
+ await model.save(resultID);
+
+ // start again, but with a new record
+ delete this.params.res_id;
+ resultID = await model.load(this.params);
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 0,
+ "should be initialized with correct value (0 as integer)");
+
+ await model.notifyChanges(resultID, { foo: "anotherverylongstring" });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 21,
+ "should be changed with correct value");
+
+ await model.save(resultID);
+ model.destroy();
+ });
+
+ QUnit.test('dont write on readonly fields unless save attribute is set', async function (assert) {
+ assert.expect(6);
+
+ this.params.fieldNames = ['foo', 'bar'];
+ this.data.partner.fields.foo.onChange = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.bar = obj.foo.length;
+ };
+ this.params.fieldsInfo = {
+ default: {
+ foo: {},
+ bar: {
+ modifiers: {
+ readonly: true,
+ },
+ force_save: true,
+ },
+ }
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], { bar: 14, foo: "verylongstring" },
+ "should only save foo field");
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], { bar: 21, foo: "anotherverylongstring" },
+ "should only save foo field");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 2,
+ "should be initialized with correct value");
+
+ await model.notifyChanges(resultID, { foo: "verylongstring" });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 14,
+ "should be changed with correct value");
+
+ await model.save(resultID);
+
+ // start again, but with a new record
+ delete this.params.res_id;
+ resultID = await model.load(this.params);
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 0,
+ "should be initialized with correct value (0 as integer)");
+
+ await model.notifyChanges(resultID, { foo: "anotherverylongstring" });
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 21,
+ "should be changed with correct value");
+
+ await model.save(resultID);
+ model.destroy();
+ });
+
+ QUnit.test('default_get with one2many values', async function (assert) {
+ assert.expect(1);
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return Promise.resolve({
+ product_ids: [[0, 0, { "name": "xdroid" }]]
+ });
+ }
+ return this._super(route, args);
+ },
+ });
+ var params = {
+ fieldNames: ['product_ids'],
+ fields: this.data.partner.fields,
+ modelName: 'partner',
+ type: 'record',
+ fieldsInfo: {
+ form: {
+ product_ids: {
+ fieldsInfo: {
+ default: { name: {} },
+ },
+ relatedFields: this.data.product.fields,
+ viewType: 'default',
+ },
+ },
+ },
+ viewType: 'form',
+ };
+ var resultID = await model.load(params);
+ assert.strictEqual(typeof resultID, 'string', "result should be a valid id");
+ model.destroy();
+ });
+
+ QUnit.test('call makeRecord with a pre-fetched many2one field', async function (assert) {
+ assert.expect(3);
+ var rpcCount = 0;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+
+ model.makeRecord('coucou', [{
+ name: 'partner_id',
+ relation: 'partner',
+ type: 'many2one',
+ value: [1, 'first partner'],
+ }], {
+ partner_id: {
+ options: {
+ no_open: true,
+ },
+ },
+ }).then(function (recordID) {
+ var record = model.get(recordID);
+ assert.deepEqual(record.fieldsInfo.default.partner_id, { options: { no_open: true } },
+ "makeRecord should have generated the fieldsInfo");
+ assert.deepEqual(record.data.partner_id.data, { id: 1, display_name: 'first partner' },
+ "many2one should contain the partner with id 1");
+ assert.strictEqual(rpcCount, 0, "makeRecord should not have done any rpc");
+ });
+ model.destroy();
+ });
+
+ QUnit.test('call makeRecord with a many2many field', async function (assert) {
+ assert.expect(5);
+ var rpcCount = 0;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+
+ var recordID = await model.makeRecord('coucou', [{
+ name: 'partner_ids',
+ fields: [{
+ name: 'id',
+ type: 'integer',
+ }, {
+ name: 'display_name',
+ type: 'char',
+ }],
+ relation: 'partner',
+ type: 'many2many',
+ value: [1, 2],
+ }]);
+ var record = model.get(recordID);
+ assert.deepEqual(record.fieldsInfo.default.partner_ids, {},
+ "makeRecord should have generated the fieldsInfo");
+ assert.strictEqual(record.data.partner_ids.count, 2,
+ "there should be 2 elements in the many2many");
+ assert.strictEqual(record.data.partner_ids.data.length, 2,
+ "many2many should be a list of length 2");
+ assert.deepEqual(record.data.partner_ids.data[0].data, { id: 1, display_name: 'first partner' },
+ "many2many should contain the partner with id 1");
+ assert.strictEqual(rpcCount, 1, "makeRecord should have done 1 rpc");
+ model.destroy();
+ });
+
+ QUnit.test('call makeRecord with a pre-fetched many2many field', async function (assert) {
+ assert.expect(5);
+ var rpcCount = 0;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+
+ var recordID = await model.makeRecord('coucou', [{
+ name: 'partner_ids',
+ fields: [{
+ name: 'id',
+ type: 'integer',
+ }, {
+ name: 'display_name',
+ type: 'char',
+ }],
+ relation: 'partner',
+ type: 'many2many',
+ value: [{
+ id: 1,
+ display_name: "first partner",
+ }, {
+ id: 2,
+ display_name: "second partner",
+ }],
+ }]);
+ var record = model.get(recordID);
+ assert.deepEqual(record.fieldsInfo.default.partner_ids, {},
+ "makeRecord should have generated the fieldsInfo");
+ assert.strictEqual(record.data.partner_ids.count, 2,
+ "there should be 2 elements in the many2many");
+ assert.strictEqual(record.data.partner_ids.data.length, 2,
+ "many2many should be a list of length 2");
+ assert.deepEqual(record.data.partner_ids.data[0].data, { id: 1, display_name: 'first partner' },
+ "many2many should contain the partner with id 1");
+ assert.strictEqual(rpcCount, 0, "makeRecord should not have done any rpc");
+ model.destroy();
+ });
+
+ QUnit.test('call makeRecord with a selection field', async function (assert) {
+ assert.expect(4);
+ var rpcCount = 0;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var recordID = await model.makeRecord('partner', [{
+ name: 'status',
+ string: 'Status',
+ type: 'selection',
+ selection: [['draft', 'Draft'], ['done', 'Done'], ['failed', 'Failed']],
+ value: 'done',
+ }]);
+ var record = model.get(recordID);
+ assert.deepEqual(record.fieldsInfo.default.status, {},
+ "makeRecord should have generated the fieldsInfo");
+ assert.strictEqual(record.data.status, 'done',
+ "should have a value 'done'");
+ assert.strictEqual(record.fields.status.selection.length, 3,
+ "should have 3 keys for selection");
+ assert.strictEqual(rpcCount, 0, "makeRecord should have done 0 rpc");
+ model.destroy();
+ });
+
+ QUnit.test('call makeRecord with a reference field', async function (assert) {
+ assert.expect(2);
+ let rpcCount = 0;
+
+ const model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+
+ const field = this.data.partner.fields.reference;
+ const recordID = await model.makeRecord('coucou', [{
+ name: 'reference',
+ type: 'reference',
+ selection: field.selection,
+ value: 'product,37',
+ }]);
+ const record = model.get(recordID);
+ assert.deepEqual(record.data.reference.data, { id: 37, display_name: 'xphone' });
+ assert.strictEqual(rpcCount, 1);
+
+ model.destroy();
+ });
+
+ QUnit.test('check id, active_id, active_ids, active_model values in record\'s context', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.product_id.context = "{'id': id, 'active_id': active_id, 'active_ids': active_ids, 'active_model': active_model}";
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ this.params.fieldNames = ['product_id'];
+
+ var resultID = await model.load(this.params);
+ var recordPartner = model.get(resultID);
+ assert.deepEqual(
+ recordPartner.getContext({ fieldName: "product_id" }),
+ { id: 2, active_id: 2, active_ids: [2], active_model: "partner" },
+ "wrong values for id, active_id, active_ids or active_model");
+
+ // Try again without record
+ this.params.res_id = undefined;
+
+ resultID = await model.load(this.params);
+ recordPartner = model.get(resultID);
+ assert.deepEqual(
+ recordPartner.getContext({ fieldName: "product_id" }),
+ { id: false, active_id: false, active_ids: [], active_model: "partner" },
+ "wrong values for id, active_id, active_ids or active_model. Have to be defined even if there is no record.");
+
+ model.destroy();
+ });
+
+ QUnit.test('load model with many2many field properly fetched', async function (assert) {
+ assert.expect(2);
+
+ this.params.fieldNames = ['category'];
+ this.params.res_id = 1;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super(route, args);
+ },
+ });
+
+ await model.load(this.params);
+ assert.verifySteps(['read'],
+ "there should be only one read");
+ model.destroy();
+ });
+
+ QUnit.test('data should contain all fields in view, default being false', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.product_ids.default = [
+ [6, 0, []],
+ [0, 0, { name: 'new' }],
+ ];
+ this.data.product.fields.date = { string: "Date", type: "date" };
+
+ var params = {
+ fieldNames: ['product_ids'],
+ modelName: 'partner',
+ fields: this.data.partner.fields,
+ fieldsInfo: {
+ form: {
+ product_ids: {
+ relatedFields: this.data.product.fields,
+ fieldsInfo: { list: { name: {}, date: {} } },
+ viewType: 'list',
+ }
+ },
+ },
+ res_id: undefined,
+ type: 'record',
+ viewType: 'form',
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ await model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.data[0].data.date, false,
+ "date value should be in data, and should be false");
+ });
+
+ model.destroy();
+ });
+
+ QUnit.test('changes are discarded when reloading from a new record', async function (assert) {
+ // practical use case: click on 'Create' to open a form view in edit
+ // mode (new record), click on 'Discard', then open an existing record
+ assert.expect(2);
+
+ this.data.partner.fields.foo.default = 'default';
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ // load a new record (default_get)
+ var params = _.extend(this.params, {
+ res_id: undefined,
+ type: 'record',
+ fieldNames: ['foo'],
+ });
+ var resultID = await model.load(params);
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'default',
+ "should be the default value");
+
+ // reload with id 2
+ resultID = await model.reload(record.id, { currentId: 2 });
+ record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'gnap',
+ "should be the value of record 2");
+
+ model.destroy();
+ });
+
+ QUnit.test('has a proper evaluation context', async function (assert) {
+ assert.expect(6);
+
+ const unpatchDate = testUtils.mock.patchDate(1997, 0, 9, 12, 0, 0);
+ this.params.fieldNames = Object.keys(this.data.partner.fields);
+ this.params.res_id = 1;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ const { evalContext } = model.get(resultID);
+ assert.strictEqual(typeof evalContext.datetime, "object");
+ assert.strictEqual(typeof evalContext.relativedelta, "object");
+ assert.strictEqual(typeof evalContext.time, "object");
+ assert.strictEqual(typeof evalContext.context_today, "function");
+ assert.strictEqual(typeof evalContext.tz_offset, "function");
+ const blackListedKeys = [
+ "time",
+ "datetime",
+ "relativedelta",
+ "context_today",
+ "tz_offset",
+ ];
+ // Remove uncomparable values from the evaluation context
+ for (const key of blackListedKeys) {
+ delete evalContext[key];
+ }
+ assert.deepEqual(evalContext, {
+ active: true,
+ active_id: 1,
+ active_ids: [1],
+ active_model: "partner",
+ bar: 1,
+ category: [12],
+ current_company_id: false,
+ current_date: moment().format('YYYY-MM-DD'),
+ today: moment().format('YYYY-MM-DD'),
+ now: moment().utc().format('YYYY-MM-DD HH:mm:ss'),
+ date: "2017-01-25",
+ display_name: "first partner",
+ foo: "blip",
+ id: 1,
+ product_id: 37,
+ product_ids: [],
+ qux: false,
+ reference: false,
+ total: 0,
+ x_active: true,
+ }, "should use the proper eval context");
+ model.destroy();
+ unpatchDate();
+ });
+
+ QUnit.test('x2manys in contexts and domains are correctly evaluated', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].product_ids = [37, 41];
+ this.params.fieldNames = Object.keys(this.data.partner.fields);
+ this.params.fieldsInfo = {
+ form: {
+ qux: {
+ context: "{'category': category, 'product_ids': product_ids}",
+ domain: "[['id', 'in', category], ['id', 'in', product_ids]]",
+ relatedFields: this.data.partner.fields,
+ },
+ category: {
+ relatedFields: this.data.partner_type.fields,
+ },
+ product_ids: {
+ relatedFields: this.data.product.fields,
+ },
+ },
+ };
+ this.params.viewType = 'form';
+ this.params.res_id = 1;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ var context = record.getContext({ fieldName: 'qux' });
+ var domain = record.getDomain({ fieldName: 'qux' });
+
+ assert.deepEqual(context, {
+ category: [12],
+ product_ids: [37, 41],
+ }, "x2many values in context manipulated client-side should be lists of ids");
+ assert.strictEqual(JSON.stringify(context),
+ "{\"category\":[[6,false,[12]]],\"product_ids\":[[4,37,false],[4,41,false]]}",
+ "x2many values in context sent to the server should be commands");
+ assert.deepEqual(domain, [
+ ['id', 'in', [12]],
+ ['id', 'in', [37, 41]],
+ ], "x2many values in domains should be lists of ids");
+ assert.strictEqual(JSON.stringify(domain),
+ "[[\"id\",\"in\",[12]],[\"id\",\"in\",[37,41]]]",
+ "x2many values in domains should be lists of ids");
+ model.destroy();
+ });
+
+ QUnit.test('fetch references in list, with not too many rpcs', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[0].reference = 'product,37';
+ this.data.partner.records[1].reference = 'product,41';
+
+ this.params.fieldNames = ['reference'];
+ this.params.domain = [];
+ this.params.groupedBy = [];
+ this.params.res_id = undefined;
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(route);
+ if (route === "/web/dataset/call_kw/product/name_get") {
+ assert.deepEqual(args.args, [[37, 41]],
+ "the name_get should contain the product ids");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+
+ assert.strictEqual(record.data[0].data.reference.data.display_name, "xphone",
+ "name_get should have been correctly fetched");
+ assert.verifySteps(["/web/dataset/search_read", "/web/dataset/call_kw/product/name_get"],
+ "should have done 2 rpc (searchread and name_get for product)");
+ model.destroy();
+ });
+
+ QUnit.test('reload a new record', async function (assert) {
+ assert.expect(6);
+
+ this.params.context = {};
+ this.params.fieldNames = ['product_id', 'category', 'product_ids'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super(route, args);
+ },
+ });
+
+ var recordID = await model.load(this.params);
+ recordID = await model.reload(recordID);
+ assert.verifySteps(['onchange', 'onchange']);
+ var record = model.get(recordID);
+ assert.strictEqual(record.data.product_id, false,
+ "m2o default value should be false");
+ assert.deepEqual(record.data.product_ids.data, [],
+ "o2m default should be []");
+ assert.deepEqual(record.data.category.data, [],
+ "m2m default should be []");
+
+ model.destroy();
+ });
+
+ QUnit.test('default_get with value false for a one2many', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.product_ids.default = false;
+ this.params.fieldNames = ['product_ids'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.deepEqual(record.data.product_ids.data, [], "o2m default should be []");
+
+ model.destroy();
+ });
+
+ QUnit.test('only x2many lists (static) should be sorted client-side', async function (assert) {
+ assert.expect(1);
+
+ this.params.modelName = 'partner_type';
+ this.params.res_id = undefined;
+ this.params.orderedBy = [{ name: 'display_name', asc: true }];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route) {
+ if (route === '/web/dataset/search_read') {
+ // simulate randomn sort form the server
+ return Promise.resolve({
+ length: 3,
+ records: [
+ { id: 12, display_name: "gold", date: "2017-01-25" },
+ { id: 15, display_name: "bronze" },
+ { id: 14, display_name: "silver" },
+ ],
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var resultID = await model.load(this.params);
+ var list = model.get(resultID);
+ assert.deepEqual(_.map(list.data, 'res_id'), [12, 15, 14],
+ "should have kept the order from the server");
+ model.destroy();
+ });
+
+ QUnit.test('onchange on a boolean field', async function (assert) {
+ assert.expect(2);
+
+ var newFields = {
+ foobool: {
+ type: 'boolean',
+ string: 'foobool',
+ },
+ foobool2: {
+ type: 'boolean',
+ string: 'foobool2',
+ },
+ };
+ _.extend(this.data.partner.fields, newFields);
+
+ this.data.partner.fields.foobool.onChange = true;
+ this.data.partner.onchanges.foobool = function (obj) {
+ if (obj.foobool) {
+ obj.foobool2 = true;
+ }
+ };
+
+ this.data.partner.records[0].foobool = false;
+ this.data.partner.records[0].foobool2 = true;
+
+ this.params.res_id = 1;
+ this.params.fieldNames = ['foobool', 'foobool2'];
+ this.params.fields = this.data.partner.fields;
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ await model.notifyChanges(resultID, { foobool2: false });
+ record = model.get(resultID);
+ assert.strictEqual(record.data.foobool2, false, "foobool2 field should be false");
+ await model.notifyChanges(resultID, { foobool: true });
+ record = model.get(resultID);
+ assert.strictEqual(record.data.foobool2, true, "foobool2 field should be true");
+ model.destroy();
+ });
+
+ QUnit.test('notifyChange DELETE_ALL on a one2many', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[1].product_ids = [37, 38];
+ this.params.fieldNames = ['product_ids'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var o2mParams = {
+ modelName: 'product',
+ fields: this.data.product.fields,
+ };
+
+ var resultID = await model.load(this.params);
+ var newRecordID = await model.load(o2mParams);
+ var record = model.get(resultID);
+ var x2mListID = record.data.product_ids.id;
+
+ assert.strictEqual(record.data.product_ids.count, 2,
+ "there should be two records in the relation");
+
+ await model.notifyChanges(resultID, {product_ids: {operation: 'ADD', id: newRecordID}});
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'ADD', id: newRecordID,
+ }], "_changes should be correct");
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 3,
+ "there should be three records in the relation");
+
+ await model.notifyChanges(resultID, {product_ids: {operation: 'DELETE_ALL'}});
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ id: 37,
+ operation: "DELETE"
+ }, {
+ id: 38,
+ operation: "DELETE"
+ }], "_changes should contain the two 'DELETE' operations");
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 0,
+ "there should be no more records in the relation");
+ model.destroy();
+ });
+
+ QUnit.test('notifyChange MULTI on a one2many', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[1].product_ids = [37, 38];
+ this.params.fieldNames = ['product_ids'];
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var o2mParams = {
+ modelName: 'product',
+ fields: this.data.product.fields,
+ };
+
+ var resultID = await model.load(this.params);
+ var newRecordID = await model.load(o2mParams);
+ var record = model.get(resultID);
+ var x2mListID = record.data.product_ids.id;
+
+ assert.strictEqual(record.data.product_ids.count, 2,
+ "there should be two records in the relation");
+
+ await model.notifyChanges(resultID, {product_ids: {
+ operation: 'MULTI',
+ commands: [{
+ operation: 'DELETE_ALL'
+ }, {
+ operation: 'ADD',
+ id: newRecordID
+ }]
+ }});
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ id: 37,
+ operation: "DELETE"
+ }, {
+ id: 38,
+ operation: "DELETE"
+ }, {
+ operation: 'ADD', id: newRecordID,
+ }], "_changes should be correct");
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 1,
+ "there should be one record in the relation");
+
+ assert.strictEqual(record.data.product_ids.data[0].id, newRecordID,
+ "the id should match");
+ });
+
+ QUnit.test('notifyChange MULTI on a many2many', async function (assert) {
+ assert.expect(3);
+
+ this.params.fieldsInfo = {
+ default: {
+ category: {
+ fieldsInfo: {default: {some_char: { context: "{'a': parent.foo}"}}},
+ relatedFields: {some_char: {type: "char"}},
+ viewType: 'default',
+ },
+ foo: {},
+ },
+ };
+
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ var resultID = await model.load(this.params);
+ var changes = {
+ category: {
+ operation: 'MULTI',
+ commands: [{
+ operation: 'ADD_M2M',
+ ids: [{id: 23}, {id: 24}, {id: 25}]
+ }, {
+ operation: 'ADD_M2M',
+ ids: [{id: 26}]
+ }]
+ }
+ };
+ await model.notifyChanges(resultID, changes);
+ var record = model.get(resultID);
+ var categoryRecord = record.data.category;
+
+ assert.strictEqual(categoryRecord.data.length, 4,
+ "there should 2 records in the relation");
+
+ await model.notifyChanges(resultID, {category: {
+ operation: 'MULTI',
+ commands: [{
+ operation: 'DELETE_ALL'
+ }, {
+ operation: 'ADD_M2M',
+ ids: [{id: 27}]
+ }]
+ }});
+ record = model.get(resultID);
+ categoryRecord = record.data.category;
+ assert.strictEqual(categoryRecord.data.length, 1,
+ "there should 1 record in the relation");
+
+ assert.strictEqual(record.data.category.data[0].data.id, 27,
+ "the id should match");
+
+ model.destroy();
+ });
+
+ QUnit.test('identify correct active field', async function(assert) {
+ assert.expect(4);
+ var model = await createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+ // check that active field is returned if present
+ this.params.res_id = 37;
+ this.params.modelName = 'product'
+ this.params.fields = this.data.product.fields;
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.equal(model.getActiveField(record), 'active', 'should have returned "active" field name');
+ // check that active field is not returned if not present
+ this.params.res_id = 42;
+ this.params.modelName = 'partner_title';
+ this.params.fields = this.data.partner_title.fields;
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.equal(model.getActiveField(record), undefined, 'should not have returned any field name');
+ // check that x_active field is returned if x_active present
+ this.params.res_id = 12;
+ this.params.modelName = 'partner_type';
+ this.params.fields = this.data.partner_type.fields;
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.equal(model.getActiveField(record), 'x_active', 'should have returned "x_active" field name');
+
+ // check that active field is returned if both active and x_active present
+ this.params.res_id = 1;
+ this.params.modelName = 'partner';
+ this.params.fields = this.data.partner.fields;
+ var resultID = await model.load(this.params);
+ var record = model.get(resultID);
+ assert.equal(model.getActiveField(record), 'active', 'should have returned "active" field name');
+ });
+ });
+});
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
new file mode 100644
index 00000000..3fb17d27
--- /dev/null
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -0,0 +1,3883 @@
+odoo.define('web.calendar_tests', function (require) {
+"use strict";
+
+const AbstractField = require('web.AbstractField');
+const fieldRegistry = require('web.field_registry');
+var AbstractStorageService = require('web.AbstractStorageService');
+var CalendarView = require('web.CalendarView');
+var CalendarRenderer = require('web.CalendarRenderer');
+var Dialog = require('web.Dialog');
+var ViewDialogs = require('web.view_dialogs');
+var fieldUtils = require('web.field_utils');
+var mixins = require('web.mixins');
+var RamStorage = require('web.RamStorage');
+var testUtils = require('web.test_utils');
+var session = require('web.session');
+
+var createActionManager = testUtils.createActionManager;
+
+CalendarRenderer.include({
+ getAvatars: function () {
+ var res = this._super.apply(this, arguments);
+ for (var k in res) {
+ res[k] = res[k].replace(/src="([^"]+)"/, 'data-src="\$1"');
+ }
+ return res;
+ }
+});
+
+
+var createCalendarView = testUtils.createCalendarView;
+
+// 2016-12-12 08:00:00
+var initialDate = new Date(2016, 11, 12, 8, 0, 0);
+initialDate = new Date(initialDate.getTime() - initialDate.getTimezoneOffset()*60*1000);
+
+function _preventScroll(ev) {
+ ev.stopImmediatePropagation();
+}
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ window.addEventListener('scroll', _preventScroll, true);
+ session.uid = -1; // TO CHECK
+ this.data = {
+ event: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ user_id: {string: "user", type: "many2one", relation: 'user', default: session.uid},
+ partner_id: {string: "user", type: "many2one", relation: 'partner', related: 'user_id.partner_id', default: 1},
+ name: {string: "name", type: "char"},
+ start_date: {string: "start date", type: "date"},
+ stop_date: {string: "stop date", type: "date"},
+ start: {string: "start datetime", type: "datetime"},
+ stop: {string: "stop datetime", type: "datetime"},
+ delay: {string: "delay", type: "float"},
+ allday: {string: "allday", type: "boolean"},
+ partner_ids: {string: "attendees", type: "one2many", relation: 'partner', default: [[6, 0, [1]]]},
+ type: {string: "type", type: "integer"},
+ event_type_id: {string: "Event_Type", type: "many2one", relation: 'event_type'},
+ color: {string: "Color", type: "integer", related: 'event_type_id.color'},
+ },
+ records: [
+ {id: 1, user_id: session.uid, partner_id: 1, name: "event 1", start: "2016-12-11 00:00:00", stop: "2016-12-11 00:00:00", allday: false, partner_ids: [1,2,3], type: 1},
+ {id: 2, user_id: session.uid, partner_id: 1, name: "event 2", start: "2016-12-12 10:55:05", stop: "2016-12-12 14:55:05", allday: false, partner_ids: [1,2], type: 3},
+ {id: 3, user_id: 4, partner_id: 4, name: "event 3", start: "2016-12-12 15:55:05", stop: "2016-12-12 16:55:05", allday: false, partner_ids: [1], type: 2},
+ {id: 4, user_id: session.uid, partner_id: 1, name: "event 4", start: "2016-12-14 15:55:05", stop: "2016-12-14 18:55:05", allday: true, partner_ids: [1], type: 2},
+ {id: 5, user_id: 4, partner_id: 4, name: "event 5", start: "2016-12-13 15:55:05", stop: "2016-12-20 18:55:05", allday: false, partner_ids: [2,3], type: 2},
+ {id: 6, user_id: session.uid, partner_id: 1, name: "event 6", start: "2016-12-18 08:00:00", stop: "2016-12-18 09:00:00", allday: false, partner_ids: [3], type: 3},
+ {id: 7, user_id: session.uid, partner_id: 1, name: "event 7", start: "2016-11-14 08:00:00", stop: "2016-11-16 17:00:00", allday: false, partner_ids: [2], type: 1},
+ ],
+ check_access_rights: function () {
+ return Promise.resolve(true);
+ }
+ },
+ user: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ partner_id: {string: "partner", type: "many2one", relation: 'partner'},
+ image: {string: "image", type: "integer"},
+ },
+ records: [
+ {id: session.uid, display_name: "user 1", partner_id: 1},
+ {id: 4, display_name: "user 4", partner_id: 4},
+ ]
+ },
+ partner: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ image: {string: "image", type: "integer"},
+ },
+ records: [
+ {id: 1, display_name: "partner 1", image: 'AAA'},
+ {id: 2, display_name: "partner 2", image: 'BBB'},
+ {id: 3, display_name: "partner 3", image: 'CCC'},
+ {id: 4, display_name: "partner 4", image: 'DDD'}
+ ]
+ },
+ event_type: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ color: {string: "Color", type: "integer"},
+ },
+ records: [
+ {id: 1, display_name: "Event Type 1", color: 1},
+ {id: 2, display_name: "Event Type 2", color: 2},
+ {id: 3, display_name: "Event Type 3 (color 4)", color: 4},
+ ]
+ },
+ filter_partner: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ user_id: {string: "user", type: "many2one", relation: 'user'},
+ partner_id: {string: "partner", type: "many2one", relation: 'partner'},
+ },
+ records: [
+ {id: 1, user_id: session.uid, partner_id: 1},
+ {id: 2, user_id: session.uid, partner_id: 2},
+ {id: 3, user_id: 4, partner_id: 3}
+ ]
+ },
+ };
+ },
+ afterEach: function () {
+ window.removeEventListener('scroll', _preventScroll, true);
+ },
+}, function () {
+
+ QUnit.module('CalendarView');
+
+ var archs = {
+ "event,false,form":
+ '<form>'+
+ '<field name="name"/>'+
+ '<field name="allday"/>'+
+ '<group attrs=\'{"invisible": [["allday","=",True]]}\' >'+
+ '<field name="start"/>'+
+ '<field name="stop"/>'+
+ '</group>'+
+ '<group attrs=\'{"invisible": [["allday","=",False]]}\' >'+
+ '<field name="start_date"/>'+
+ '<field name="stop_date"/>'+
+ '</group>'+
+ '</form>',
+ "event,1,form":
+ '<form>' +
+ '<field name="allday" invisible="1"/>' +
+ '<field name="start" attrs=\'{"invisible": [["allday","=",false]]}\'/>' +
+ '<field name="stop" attrs=\'{"invisible": [["allday","=",true]]}\'/>' +
+ '</form>',
+ };
+
+ QUnit.test('simple calendar rendering', async function (assert) {
+ assert.expect(24);
+
+ this.data.event.records.push({
+ id: 8,
+ user_id: session.uid,
+ partner_id: false,
+ name: "event 7",
+ start: "2016-12-18 09:00:00",
+ stop: "2016-12-18 10:00:00",
+ allday: false,
+ partner_ids: [2],
+ type: 1
+ });
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '<field name="partner_id" filters="1" invisible="1"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length,
+ "should instance of fullcalendar");
+
+ var $sidebar = calendar.$('.o_calendar_sidebar');
+
+ // test view scales
+ assert.containsN(calendar, '.fc-event', 9,
+ "should display 9 events on the week (4 event + 5 days event)");
+ assert.containsN($sidebar, 'tr:has(.ui-state-active) td', 7,
+ "week scale should highlight 7 days in mini calendar");
+
+ await testUtils.dom.click(calendar.$buttons.find('.o_calendar_button_day')); // display only one day
+ assert.containsN(calendar, '.fc-event', 2, "should display 2 events on the day");
+ assert.containsOnce($sidebar, '.o_selected_range',
+ "should highlight the target day in mini calendar");
+
+ await testUtils.dom.click(calendar.$buttons.find('.o_calendar_button_month')); // display all the month
+ assert.containsN(calendar, '.fc-event', 7,
+ "should display 7 events on the month (5 events + 2 week event - 1 'event 6' is filtered + 1 'Undefined event')");
+ assert.containsN($sidebar, 'td a', 31,
+ "month scale should highlight all days in mini calendar");
+
+ // test filters
+ assert.containsN($sidebar, '.o_calendar_filter', 2, "should display 2 filters");
+
+ var $typeFilter = $sidebar.find('.o_calendar_filter:has(h5:contains(user))');
+ assert.ok($typeFilter.length, "should display 'user' filter");
+ assert.containsN($typeFilter, '.o_calendar_filter_item', 3, "should display 3 filter items for 'user'");
+
+ // filters which has no value should show with string "Undefined", should not have any user image and should show at the last
+ assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last').data('value'), false, "filters having false value should be displayed at last in filter items");
+ assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last .o_cw_filter_title').text(), "Undefined", "filters having false value should display 'Undefined' string");
+ assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last label img').length, 0, "filters having false value should not have any user image");
+
+ var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h5:contains(attendees))');
+ assert.ok($attendeesFilter.length, "should display 'attendees' filter");
+ assert.containsN($attendeesFilter, '.o_calendar_filter_item', 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)");
+ assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter");
+
+ assert.containsN(calendar, '.fc-event', 7,
+ "should display 7 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)");
+ await testUtils.dom.click(calendar.$('.o_calendar_filter input[type="checkbox"]').first());
+ assert.containsN(calendar, '.fc-event', 4, "should now only display 4 event");
+ await testUtils.dom.click(calendar.$('.o_calendar_filter input[type="checkbox"]').eq(1));
+ assert.containsNone(calendar, '.fc-event', "should not display any event anymore");
+
+ // test search bar in filter
+ await testUtils.dom.click($sidebar.find('input[type="text"]'));
+ assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').length, 2,"should display 2 choices in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice
+ await testUtils.dom.click($('ul.ui-autocomplete li:first'));
+ assert.containsN($sidebar, '.o_calendar_filter:has(h5:contains(attendees)) .o_calendar_filter_item', 4, "should display 4 filter items for 'attendees'");
+ await testUtils.dom.click($sidebar.find('input[type="text"]'));
+ assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').text(), "partner 4", "should display the last choice in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice
+ await testUtils.dom.click($sidebar.find('.o_calendar_filter_item .o_remove').first(), {allowInvisible: true});
+ assert.ok($('.modal-footer button.btn:contains(Ok)').length, "should display the confirm message");
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Ok)'));
+ assert.containsN($sidebar, '.o_calendar_filter:has(h5:contains(attendees)) .o_calendar_filter_item', 3, "click on remove then should display 3 filter items for 'attendees'");
+ calendar.destroy();
+ });
+
+ QUnit.test('delete attribute on calendar doesn\'t show delete button in popover', async function (assert) {
+ assert.expect(2);
+
+ const calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" ' +
+ 'string="Events" ' +
+ 'event_open_popup="true" ' +
+ 'date_start="start" ' +
+ 'date_stop="stop" ' +
+ 'all_day="allday" ' +
+ 'delete="0" ' +
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content'));
+
+ assert.containsOnce(calendar, '.o_cw_popover',
+ "should open a popover clicking on event");
+ assert.containsNone(calendar, '.o_cw_popover .o_cw_popover_delete',
+ "should not have the 'Delete' Button");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('breadcrumbs are updated with the displayed period', async function (assert) {
+ assert.expect(4);
+
+ var archs = {
+ 'event,1,calendar': '<calendar date_start="start" date_stop="stop" all_day="allday"/>',
+ 'event,false,search': '<search></search>',
+ };
+
+ var actions = [{
+ id: 1,
+ flags: {
+ initialDate: initialDate,
+ },
+ name: 'Meetings Test',
+ res_model: 'event',
+ type: 'ir.actions.act_window',
+ views: [[1, 'calendar']],
+ }];
+
+ var actionManager = await createActionManager({
+ actions: actions,
+ archs: archs,
+ data: this.data,
+ });
+
+ await actionManager.doAction(1);
+ await testUtils.nextTick();
+
+ // displays month mode by default
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
+ 'Meetings Test (Dec 11 – 17, 2016)', "should display the current week");
+
+ // switch to day mode
+ await testUtils.dom.click($('.o_control_panel .o_calendar_button_day'));
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
+ 'Meetings Test (December 12, 2016)', "should display the current day");
+
+ // switch to month mode
+ await testUtils.dom.click($('.o_control_panel .o_calendar_button_month'));
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
+ 'Meetings Test (December 2016)', "should display the current month");
+
+ // switch to year mode
+ await testUtils.dom.click($('.o_control_panel .o_calendar_button_year'));
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
+ 'Meetings Test (2016)', "should display the current year");
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('create and change events', async function (assert) {
+ assert.expect(28);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="month"/>',
+ archs: archs,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {name: 'event 4 modified'}, "should update the record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.ok(calendar.$('.fc-dayGridMonth-view').length, "should display in month mode");
+
+ // click on an existing event to open the formViewDialog
+
+ await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content'));
+
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ assert.ok(calendar.$('.o_cw_popover .o_cw_popover_edit').length, "popover should have an edit button");
+ assert.ok(calendar.$('.o_cw_popover .o_cw_popover_delete').length, "popover should have a delete button");
+ assert.ok(calendar.$('.o_cw_popover .o_cw_popover_close').length, "popover should have a close button");
+
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit'));
+
+ assert.ok($('.modal-body').length, "should open the form view in dialog when click on event");
+
+ await testUtils.fields.editInput($('.modal-body input:first'), 'event 4 modified');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Save)'));
+
+ assert.notOk($('.modal-body').length, "save button should close the modal");
+
+ // create a new event, quick create only
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ assert.ok($('.modal-sm').length, "should open the quick create dialog");
+
+ await testUtils.fields.editInput($('.modal-body input:first'), 'new event in quick create');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Create)'));
+
+ assert.strictEqual(calendar.$('.fc-event:contains(new event in quick create)').length, 1, "should display the new record after quick create");
+ assert.containsN(calendar, 'td.fc-event-container[colspan]', 2, "should the new record have only one day");
+
+ // create a new event, quick create only (validated by pressing enter key)
+
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ assert.ok($('.modal-sm').length, "should open the quick create dialog");
+
+ await testUtils.fields.editInput($('.modal-body input:first'),
+ 'new event in quick create validated by pressing enter key.');
+ $('.modal-body input:first')
+ .val('new event in quick create validated by pressing enter key.')
+ .trigger($.Event('keyup', {keyCode: $.ui.keyCode.ENTER}))
+ .trigger($.Event('keyup', {keyCode: $.ui.keyCode.ENTER}));
+ await testUtils.nextTick();
+ assert.containsOnce(calendar, '.fc-event:contains(new event in quick create validated by pressing enter key.)',
+ "should display the new record by pressing enter key");
+
+
+ // create a new event and edit it
+
+ $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)');
+
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ assert.strictEqual($('.modal-sm').length, 1, "should open the quick create dialog");
+
+ testUtils.fields.editInput($('.modal-body input:first'), 'coucou');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Edit)'));
+
+ assert.strictEqual($('.modal-lg .o_form_view').length, 1, "should open the slow create dialog");
+ assert.strictEqual($('.modal-lg .modal-title').text(), "Create: Events",
+ "should use the string attribute as modal title");
+ assert.strictEqual($('.modal-lg .o_form_view input[name="name"]').val(), "coucou",
+ "should have set the name from the quick create dialog");
+
+ await testUtils.dom.click($('.modal-lg button.btn:contains(Save)'));
+
+ assert.strictEqual(calendar.$('.fc-event:contains(coucou)').length, 1,
+ "should display the new record with string attribute");
+
+ // create a new event with 2 days
+
+ $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)');
+
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell.next(), "mousemove");
+ testUtils.dom.triggerMouseEvent($cell.next(), "mouseup");
+ await testUtils.nextTick();
+
+ testUtils.fields.editInput($('.modal-dialog input:first'), 'new event in quick create 2');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Edit)'));
+
+ assert.strictEqual($('.modal-lg input:first').val(),'new event in quick create 2',
+ "should open the formViewDialog with default values");
+
+ await testUtils.dom.click($('.modal-lg button.btn:contains(Save)'));
+
+ assert.notOk($('.modal').length, "should close dialogs");
+ var $newevent2 = calendar.$('.fc-event:contains(new event in quick create 2)');
+ assert.ok($newevent2.length, "should display the 2 days new record");
+ assert.hasAttrValue($newevent2.closest('.fc-event-container'),
+ 'colspan', "2","the new record should have 2 days");
+
+ await testUtils.dom.click(calendar.$('.fc-event:contains(new event in quick create 2) .fc-content'));
+ var $popover_description = calendar.$('.o_cw_popover .o_cw_body .list-group-item');
+ assert.strictEqual($popover_description.children()[1].textContent,'December 20-21, 2016',
+ "The popover description should indicate the correct range");
+ assert.strictEqual($popover_description.children()[2].textContent,'(2 days)',
+ "The popover description should indicate 2 days");
+ await testUtils.dom.click(calendar.$('.o_cw_popover .fa-close'));
+
+ // delete the a record
+
+ await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content'));
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_delete'));
+ assert.ok($('.modal-footer button.btn:contains(Ok)').length, "should display the confirm message");
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Ok)'));
+ assert.notOk(calendar.$('.fc-event:contains(event 4) .fc-content').length, "the record should be deleted");
+
+ assert.containsN(calendar, '.fc-event-container .fc-event', 10, "should display 10 events");
+ // move to next month
+ await testUtils.dom.click(calendar.$buttons.find('.o_calendar_button_next'));
+
+ assert.containsNone(calendar, '.fc-event-container .fc-event', "should display 0 events");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('quickcreate switching to actual create for required fields', async function (assert) {
+ assert.expect(4);
+
+ var event = $.Event();
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ return Promise.reject({
+ message: {
+ code: 200,
+ data: {},
+ message: "Odoo server error",
+ },
+ event: event
+ });
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // create a new event
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ assert.strictEqual($('.modal-sm .modal-title').text(), 'Create: Events',
+ "should open the quick create dialog");
+
+ await testUtils.fields.editInput($('.modal-body input:first'), 'new event in quick create');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Create)'));
+ await testUtils.nextTick();
+
+ // If the event is not default-prevented, a traceback will be raised, which we do not want
+ assert.ok(event.isDefaultPrevented(), "fail deferred event should have been default-prevented");
+
+ assert.strictEqual($('.modal-lg .modal-title').text(), 'Create: Events',
+ "should have switched to a bigger modal for an actual create rather than quickcreate");
+ assert.strictEqual($('.modal-lg main .o_form_view.o_form_editable').length, 1,
+ "should open the full event form view in a dialog");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('open multiple event form at the same time', async function (assert) {
+ assert.expect(2);
+
+ var prom = testUtils.makeTestPromise();
+ var counter = 0;
+ testUtils.mock.patch(ViewDialogs.FormViewDialog, {
+ open: function () {
+ counter++;
+ this.options = _.omit(this.options, 'fields_view'); // force loadFieldView
+ return this._super.apply(this, arguments);
+ },
+ loadFieldView: function () {
+ var self = this;
+ var args = arguments;
+ var _super = this._super;
+ return prom.then(function () {
+ return _super.apply(self, args);
+ });
+ },
+ });
+
+ var event = $.Event();
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'quick_add="False" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="month">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ for (var i = 0; i < 5; i++) {
+ await testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ await testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ }
+ prom.resolve();
+ await testUtils.nextTick();
+ assert.equal(counter, 5, "there should had been 5 attemps to open a modal");
+ assert.containsOnce($('body'), '.modal', "there should be only one open modal");
+
+ calendar.destroy();
+ testUtils.mock.unpatch(ViewDialogs.FormViewDialog);
+ });
+
+ QUnit.test('create event with timezone in week mode European locale', async function (assert) {
+ assert.expect(5);
+
+ this.data.event.records = [];
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week">'+
+ '<field name="name"/>'+
+ '<field name="start"/>'+
+ '<field name="allday"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ translateParameters: { // Avoid issues due to localization formats
+ time_format: "%H:%M:%S",
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ assert.deepEqual(args.kwargs.context, {
+ "default_start": "2016-12-13 06:00:00",
+ "default_stop": "2016-12-13 08:00:00",
+ "default_allday": null
+ },
+ "should send the context to create events");
+ }
+ return this._super(route, args);
+ },
+ }, {positionalClicks: true});
+
+ var top = calendar.$('.fc-axis:contains(8:00)').offset().top + 5;
+ var left = calendar.$('.fc-day:eq(2)').offset().left + 5;
+
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove");
+
+ assert.strictEqual(calendar.$('.fc-content .fc-time').text(), "8:00 - 10:00",
+ "should display the time in the calendar sticker");
+
+ await testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup");
+ await testUtils.nextTick();
+ await testUtils.fields.editInput($('.modal input:first'), 'new event');
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ var $newevent = calendar.$('.fc-event:contains(new event)');
+
+ assert.strictEqual($newevent.find('.o_event_title').text(), "new event",
+ "should display the new event with title");
+
+ assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record,
+ {
+ display_name: "new event",
+ start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}),
+ stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}),
+ allday: false,
+ name: "new event",
+ id: 1
+ },
+ "the new record should have the utc datetime (quickCreate)");
+
+ // delete record
+
+ await testUtils.dom.click($newevent);
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_delete'));
+ await testUtils.dom.click($('.modal button.btn-primary:contains(Ok)'));
+ assert.containsNone(calendar, '.fc-content', "should delete the record");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('default week start (US)', function (assert) {
+ // if not given any option, default week start is on Sunday
+ assert.expect(3);
+ var done = assert.async();
+
+ createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week">'+
+ '</calendar>',
+ archs: archs,
+
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, [
+ ["start","<=","2016-12-17 23:59:59"],
+ ["stop",">=","2016-12-11 00:00:00"]
+ ],
+ 'The domain to search events in should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ }).then(function (calendar) {
+ assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Sun 11",
+ "The first day of the week should be Sunday");
+ assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sat 17",
+ "The last day of the week should be Saturday");
+ calendar.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('European week start', function (assert) {
+ // the week start depends on the locale
+ assert.expect(3);
+ var done = assert.async();
+
+ createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week">'+
+ '</calendar>',
+ archs: archs,
+
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ translateParameters: {
+ week_start: 1,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, [
+ ["start","<=","2016-12-18 23:59:59"],
+ ["stop",">=","2016-12-12 00:00:00"]
+ ],
+ 'The domain to search events in should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ }).then(function (calendar) {
+ assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Mon 12",
+ "The first day of the week should be Monday");
+ assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sun 18",
+ "The last day of the week should be Sunday");
+ calendar.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('week numbering', function (assert) {
+ // week number depends on the week start, which depends on the locale
+ // the calendar library uses numbers [0 .. 6], while Odoo uses [1 .. 7]
+ // so if the modulo is not done, the week number is incorrect
+ assert.expect(1);
+ var done = assert.async();
+
+ createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week">'+
+ '</calendar>',
+ archs: archs,
+
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ translateParameters: {
+ week_start: 7,
+ },
+ }).then(function (calendar) {
+ assert.strictEqual(calendar.$('.fc-week-number').text(), "Week 51",
+ "We should be on the 51st week");
+ calendar.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('render popover', async function (assert) {
+ assert.expect(14);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week">'+
+ '<field name="name" string="Custom Name"/>'+
+ '<field name="partner_id"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ await testUtils.dom.click($('.fc-event:contains(event 4)'));
+
+ assert.containsOnce(calendar, '.o_cw_popover', "should open a popover clicking on event");
+ assert.strictEqual(calendar.$('.o_cw_popover .popover-header').text(), 'event 4', "popover should have a title 'event 4'");
+ assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_edit', "popover should have an edit button");
+ assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_delete', "popover should have a delete button");
+ assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_close', "popover should have a close button");
+
+ assert.strictEqual(calendar.$('.o_cw_popover .list-group-item:first b.text-capitalize').text(), 'Wednesday, December 14, 2016', "should display date 'Wednesday, December 14, 2016'");
+ assert.containsN(calendar, '.o_cw_popover .o_cw_popover_fields_secondary .list-group-item', 2, "popover should have a two fields");
+
+ assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:first .o_field_char', "should apply char widget");
+ assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:first strong').text(), 'Custom Name : ', "label should be a 'Custom Name'");
+ assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:first .o_field_char').text(), 'event 4', "value should be a 'event 4'");
+
+ assert.containsOnce(calendar, '.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_form_uri', "should apply m20 widget");
+ assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last strong').text(), 'user : ', "label should be a 'user'");
+ assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_form_uri').text(), 'partner 1', "value should be a 'partner 1'");
+
+ await testUtils.dom.click($('.o_cw_popover .o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover', "should close a popover");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('render popover with modifiers', async function (assert) {
+ assert.expect(3);
+
+ this.data.event.fields.priority = {string: "Priority", type: "selection", selection: [['0', 'Normal'], ['1', 'Important']],};
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week">'+
+ '<field name="priority" widget="priority" readonly="1"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ await testUtils.dom.click($('.fc-event:contains(event 4)'));
+
+ assert.containsOnce(calendar, '.o_cw_popover', "should open a popover clicking on event");
+ assert.containsOnce(calendar, '.o_cw_popover .o_priority span.o_priority_star', "priority field should not be editable");
+
+ await testUtils.dom.click($('.o_cw_popover .o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover', "should close a popover");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('attributes hide_date and hide_time', async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'hide_date="true" '+
+ 'hide_time="true" '+
+ 'mode="month">'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ await testUtils.dom.click($('.fc-event:contains(event 4)'));
+ assert.containsNone(calendar, '.o_cw_popover .list-group-item', "popover should not contain date/time");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create event with timezone in week mode with formViewDialog European locale', async function (assert) {
+ assert.expect(8);
+
+ this.data.event.records = [];
+ this.data.event.onchanges = {
+ allday: function (obj) {
+ if (obj.allday) {
+ obj.start_date = obj.start && obj.start.split(' ')[0] || obj.start_date;
+ obj.stop_date = obj.stop && obj.stop.split(' ')[0] || obj.stop_date || obj.start_date;
+ } else {
+ obj.start = obj.start_date && (obj.start_date + ' 00:00:00') || obj.start;
+ obj.stop = obj.stop_date && (obj.stop_date + ' 00:00:00') || obj.stop || obj.start;
+ }
+ }
+ };
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ translateParameters: { // Avoid issues due to localization formats
+ time_format: "%H:%M:%S",
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ assert.deepEqual(args.kwargs.context, {
+ "default_name": "new event",
+ "default_start": "2016-12-13 06:00:00",
+ "default_stop": "2016-12-13 08:00:00",
+ "default_allday": null
+ },
+ "should send the context to create events");
+ }
+ if (args.method === "write") {
+ assert.deepEqual(args.args[1], expectedEvent,
+ "should move the event");
+ }
+ return this._super(route, args);
+ },
+ }, {positionalClicks: true});
+
+ var top = calendar.$('.fc-axis:contains(8:00)').offset().top + 5;
+ var left = calendar.$('.fc-day:eq(2)').offset().left + 5;
+
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup");
+ await testUtils.nextTick();
+ await testUtils.fields.editInput($('.modal input:first'), 'new event');
+ await testUtils.dom.click($('.modal button.btn:contains(Edit)'));
+
+ assert.strictEqual($('.o_field_widget[name="start"] input').val(),
+ "12/13/2016 08:00:00", "should display the datetime");
+
+ await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input'));
+ await testUtils.nextTick();
+ assert.strictEqual($('input[name="start_date"]').val(),
+ "12/13/2016", "should display the date");
+
+ await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input'));
+
+ assert.strictEqual($('.o_field_widget[name="start"] input').val(),
+ "12/13/2016 02:00:00", "should display the datetime from the date with the timezone");
+
+ // use datepicker to enter a date: 12/13/2016 08:00:00
+ testUtils.dom.openDatepicker($('.o_field_widget[name="start"].o_datepicker'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(08)'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]'));
+
+ // use datepicker to enter a date: 12/13/2016 10:00:00
+ testUtils.dom.openDatepicker($('.o_field_widget[name="stop"].o_datepicker'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(10)'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]'));
+
+ await testUtils.dom.click($('.modal-lg button.btn:contains(Save)'));
+ var $newevent = calendar.$('.fc-event:contains(new event)');
+
+ assert.strictEqual($newevent.find('.o_event_title').text(), "new event",
+ "should display the new event with title");
+
+ assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record,
+ {
+ display_name: "new event",
+ start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}),
+ stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}),
+ allday: false,
+ name: "new event",
+ id: 1
+ },
+ "the new record should have the utc datetime (formViewDialog)");
+
+ var pos = calendar.$('.fc-content').offset();
+ left = pos.left + 5;
+ top = pos.top + 5;
+
+ // Mode this event to another day
+ var expectedEvent = {
+ "allday": false,
+ "start": "2016-12-12 06:00:00",
+ "stop": "2016-12-12 08:00:00"
+ };
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ left = calendar.$('.fc-day:eq(1)').offset().left + 15;
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup");
+ await testUtils.nextTick();
+
+ // Move to "All day"
+ expectedEvent = {
+ "allday": true,
+ "start": "2016-12-12 00:00:00",
+ "stop": "2016-12-12 00:00:00"
+ };
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ top = calendar.$('.fc-day:eq(1)').offset().top + 15;
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup");
+ await testUtils.nextTick();
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create event with timezone in week mode American locale', async function (assert) {
+ assert.expect(5);
+
+ this.data.event.records = [];
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week">'+
+ '<field name="name"/>'+
+ '<field name="start"/>'+
+ '<field name="allday"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ translateParameters: { // Avoid issues due to localization formats
+ time_format: "%I:%M:%S",
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ assert.deepEqual(args.kwargs.context, {
+ "default_start": "2016-12-13 06:00:00",
+ "default_stop": "2016-12-13 08:00:00",
+ "default_allday": null
+ },
+ "should send the context to create events");
+ }
+ return this._super(route, args);
+ },
+ }, {positionalClicks: true});
+
+ var top = calendar.$('.fc-axis:contains(8am)').offset().top + 5;
+ var left = calendar.$('.fc-day:eq(2)').offset().left + 5;
+
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove");
+
+ assert.strictEqual(calendar.$('.fc-content .fc-time').text(), "8:00 - 10:00",
+ "should display the time in the calendar sticker");
+
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup");
+ await testUtils.nextTick();
+ testUtils.fields.editInput($('.modal input:first'), 'new event');
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ var $newevent = calendar.$('.fc-event:contains(new event)');
+
+ assert.strictEqual($newevent.find('.o_event_title').text(), "new event",
+ "should display the new event with title");
+
+ assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record,
+ {
+ display_name: "new event",
+ start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}),
+ stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}),
+ allday: false,
+ name: "new event",
+ id: 1
+ },
+ "the new record should have the utc datetime (quickCreate)");
+
+ // delete record
+
+ await testUtils.dom.click($newevent);
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_delete'));
+ await testUtils.dom.click($('.modal button.btn-primary:contains(Ok)'));
+ assert.containsNone(calendar, '.fc-content', "should delete the record");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('fetch event when being in timezone', async function (assert) {
+ assert.expect(3);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week" >'+
+ '<field name="name"/>'+
+ '<field name="start"/>'+
+ '<field name="allday"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 660;
+ },
+ },
+
+ mockRPC: async function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, [
+ ["start", "<=", "2016-12-17 12:59:59"], // in UTC. which is 2016-12-17 23:59:59 in TZ Sydney 11 hours later
+ ["stop", ">=", "2016-12-10 13:00:00"] // in UTC. which is 2016-12-11 00:00:00 in TZ Sydney 11 hours later
+ ], 'The domain should contain the right range');
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(calendar.$('.fc-day-header:first').text(), 'Sun 11',
+ 'The calendar start date should be 2016-12-11');
+ assert.strictEqual(calendar.$('.fc-day-header:last()').text(), 'Sat 17',
+ 'The calendar start date should be 2016-12-17');
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create event with timezone in week mode with formViewDialog American locale', async function (assert) {
+ assert.expect(8);
+
+ this.data.event.records = [];
+ this.data.event.onchanges = {
+ allday: function (obj) {
+ if (obj.allday) {
+ obj.start_date = obj.start && obj.start.split(' ')[0] || obj.start_date;
+ obj.stop_date = obj.stop && obj.stop.split(' ')[0] || obj.stop_date || obj.start_date;
+ } else {
+ obj.start = obj.start_date && (obj.start_date + ' 00:00:00') || obj.start;
+ obj.stop = obj.stop_date && (obj.stop_date + ' 00:00:00') || obj.stop || obj.start;
+ }
+ }
+ };
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ translateParameters: { // Avoid issues due to localization formats
+ time_format: "%I:%M:%S",
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ assert.deepEqual(args.kwargs.context, {
+ "default_name": "new event",
+ "default_start": "2016-12-13 06:00:00",
+ "default_stop": "2016-12-13 08:00:00",
+ "default_allday": null
+ },
+ "should send the context to create events");
+ }
+ if (args.method === "write") {
+ assert.deepEqual(args.args[1], expectedEvent,
+ "should move the event");
+ }
+ return this._super(route, args);
+ },
+ }, {positionalClicks: true});
+
+ var top = calendar.$('.fc-axis:contains(8am)').offset().top + 5;
+ var left = calendar.$('.fc-day:eq(2)').offset().left + 5;
+
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 60, "mouseup");
+ await testUtils.nextTick();
+ testUtils.fields.editInput($('.modal input:first'), 'new event');
+ await testUtils.dom.click($('.modal button.btn:contains(Edit)'));
+
+ assert.strictEqual($('.o_field_widget[name="start"] input').val(), "12/13/2016 08:00:00",
+ "should display the datetime");
+
+ await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input'));
+
+ assert.strictEqual($('.o_field_widget[name="start_date"] input').val(), "12/13/2016",
+ "should display the date");
+
+ await testUtils.dom.click($('.modal-lg .o_field_boolean[name="allday"] input'));
+
+ assert.strictEqual($('.o_field_widget[name="start"] input').val(), "12/13/2016 02:00:00",
+ "should display the datetime from the date with the timezone");
+
+ // use datepicker to enter a date: 12/13/2016 08:00:00
+ testUtils.dom.openDatepicker($('.o_field_widget[name="start"].o_datepicker'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(08)'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]'));
+
+ // use datepicker to enter a date: 12/13/2016 10:00:00
+ testUtils.dom.openDatepicker($('.o_field_widget[name="stop"].o_datepicker'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(10)'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]'));
+
+ await testUtils.dom.click($('.modal-lg button.btn:contains(Save)'));
+ var $newevent = calendar.$('.fc-event:contains(new event)');
+
+ assert.strictEqual($newevent.find('.o_event_title').text(), "new event",
+ "should display the new event with title");
+
+ assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record,
+ {
+ display_name: "new event",
+ start: fieldUtils.parse.datetime("2016-12-13 06:00:00", this.data.event.fields.start, {isUTC: true}),
+ stop: fieldUtils.parse.datetime("2016-12-13 08:00:00", this.data.event.fields.stop, {isUTC: true}),
+ allday: false,
+ name: "new event",
+ id: 1
+ },
+ "the new record should have the utc datetime (formViewDialog)");
+
+ var pos = calendar.$('.fc-content').offset();
+ left = pos.left + 5;
+ top = pos.top + 5;
+
+ // Mode this event to another day
+ var expectedEvent = {
+ "allday": false,
+ "start": "2016-12-12 06:00:00",
+ "stop": "2016-12-12 08:00:00"
+ };
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ left = calendar.$('.fc-day:eq(1)').offset().left + 15;
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup");
+ await testUtils.nextTick();
+
+ // Move to "All day"
+ expectedEvent = {
+ "allday": true,
+ "start": "2016-12-12 00:00:00",
+ "stop": "2016-12-12 00:00:00"
+ };
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ top = calendar.$('.fc-day:eq(1)').offset().top + 15;
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup");
+ await testUtils.nextTick();
+
+ calendar.destroy();
+ });
+
+ QUnit.test('check calendar week column timeformat', async function (assert) {
+ assert.expect(2);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar date_start="start"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ translateParameters: {
+ time_format: "%I:%M:%S",
+ },
+ });
+
+ assert.strictEqual(calendar.$('.fc-axis:contains(8am)').length, 1, "calendar should show according to timeformat");
+ assert.strictEqual(calendar.$('.fc-axis:contains(11pm)').length, 1,
+ "event time format should 12 hour");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create all day event in week mode', async function (assert) {
+ assert.expect(3);
+
+ this.data.event.records = [];
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ }, {positionalClicks: true});
+
+ var pos = calendar.$('.fc-bg td:eq(4)').offset();
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ pos = calendar.$('.fc-bg td:eq(5)').offset();
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup");
+ await testUtils.nextTick();
+
+ testUtils.fields.editInput($('.modal input:first'), 'new event');
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ var $newevent = calendar.$('.fc-event:contains(new event)');
+
+ assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent",
+ "should display the new event with time and title");
+ assert.hasAttrValue($newevent.parent(), 'colspan', "2",
+ "should appear over two days.");
+
+ assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record,
+ {
+ display_name: "new event",
+ start: fieldUtils.parse.datetime("2016-12-14 00:00:00", this.data.event.fields.start, {isUTC: true}),
+ stop: fieldUtils.parse.datetime("2016-12-15 00:00:00", this.data.event.fields.stop, {isUTC: true}),
+ allday: true,
+ name: "new event",
+ id: 1
+ },
+ "the new record should have the utc datetime (quickCreate)");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create event with default context (no quickCreate)', async function (assert) {
+ assert.expect(3);
+
+ this.data.event.records = [];
+
+ const calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ `<calendar
+ class="o_calendar_test"
+ date_start="start"
+ date_stop="stop"
+ mode="week"
+ all_day="allday"
+ quick_add="False"/>`,
+ archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset() {
+ return 120;
+ },
+ },
+ context: {
+ default_name: 'New',
+ },
+ intercepts: {
+ do_action(ev) {
+ assert.step('do_action');
+ assert.deepEqual(ev.data.action.context, {
+ default_name: "New",
+ default_start: "2016-12-14 00:00:00",
+ default_stop: "2016-12-15 00:00:00",
+ default_allday: true,
+ },
+ "should send the correct data to create events");
+ },
+ },
+ }, { positionalClicks: true });
+
+ var pos = calendar.$('.fc-bg td:eq(4)').offset();
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(pos.left + 15, pos.top + 15, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ pos = calendar.$('.fc-bg td:eq(5)').offset();
+ testUtils.dom.triggerPositionalMouseEvent(pos.left + 15, pos.top + 15, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(pos.left + 15, pos.top + 15, "mouseup");
+ assert.verifySteps(['do_action']);
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create all day event in week mode (no quickCreate)', async function (assert) {
+ assert.expect(1);
+
+ this.data.event.records = [];
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week" '+
+ 'all_day="allday" '+
+ 'quick_add="False"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ intercepts: {
+ do_action: function (event) {
+ assert.deepEqual(event.data.action.context, {
+ default_start: "2016-12-14 00:00:00",
+ default_stop: "2016-12-15 00:00:00",
+ default_allday: true,
+ },
+ "should send the correct data to create events");
+ },
+ },
+ }, {positionalClicks: true});
+
+ var pos = calendar.$('.fc-bg td:eq(4)').offset();
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ pos = calendar.$('.fc-bg td:eq(5)').offset();
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create event in month mode', async function (assert) {
+ assert.expect(4);
+
+ this.data.event.records = [];
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ assert.deepEqual(args.args[0], {
+ "name": "new event",
+ "start": "2016-12-14 05:00:00",
+ "stop": "2016-12-15 17:00:00",
+ },
+ "should send the correct data to create events");
+ }
+ return this._super(route, args);
+ },
+ }, {positionalClicks: true});
+
+ var pos = calendar.$('.fc-bg td:eq(17)').offset();
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ pos = calendar.$('.fc-bg td:eq(18)').offset();
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup");
+ await testUtils.nextTick();
+
+ testUtils.fields.editInput($('.modal input:first'), 'new event');
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ var $newevent = calendar.$('.fc-event:contains(new event)');
+
+ assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent",
+ "should display the new event with time and title");
+ assert.hasAttrValue($newevent.parent(), 'colspan', "2",
+ "should appear over two days.");
+
+ assert.deepEqual($newevent[0].fcSeg.eventRange.def.extendedProps.record, {
+ display_name: "new event",
+ start: fieldUtils.parse.datetime("2016-12-14 05:00:00", this.data.event.fields.start, {isUTC: true}),
+ stop: fieldUtils.parse.datetime("2016-12-15 17:00:00", this.data.event.fields.stop, {isUTC: true}),
+ name: "new event",
+ id: 1
+ }, "the new record should have the utc datetime (quickCreate)");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('use mini calendar', async function (assert) {
+ assert.expect(12);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ });
+
+ assert.containsOnce(calendar, '.fc-timeGridWeek-view', "should be in week mode");
+ assert.containsN(calendar, '.fc-event', 9, "should display 9 events on the week (4 event + 5 days event)");
+ await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(19)'));
+ // Clicking on a day in another week should switch to the other week view
+ assert.containsOnce(calendar, '.fc-timeGridWeek-view', "should be in week mode");
+ assert.containsN(calendar, '.fc-event', 4, "should display 4 events on the week (1 event + 3 days event)");
+ // Clicking on a day in the same week should switch to that particular day view
+ await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)'));
+ assert.containsOnce(calendar, '.fc-timeGridDay-view', "should be in day mode");
+ assert.containsN(calendar, '.fc-event', 2, "should display 2 events on the day");
+ // Clicking on the same day should toggle between day, month and week views
+ await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)'));
+ assert.containsOnce(calendar, '.fc-dayGridMonth-view', "should be in month mode");
+ assert.containsN(calendar, '.fc-event', 7, "should display 7 events on the month (event 5 is on multiple weeks and generates to .fc-event)");
+ await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)'));
+ assert.containsOnce(calendar, '.fc-timeGridWeek-view', "should be in week mode");
+ assert.containsN(calendar, '.fc-event', 4, "should display 4 events on the week (1 event + 3 days event)");
+ await testUtils.dom.click(calendar.$('.o_calendar_mini a:contains(18)'));
+ assert.containsOnce(calendar, '.fc-timeGridDay-view', "should be in day mode");
+ assert.containsN(calendar, '.fc-event', 2, "should display 2 events on the day");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('rendering, with many2many', async function (assert) {
+ assert.expect(5);
+
+ this.data.event.fields.partner_ids.type = 'many2many';
+ this.data.event.records[0].partner_ids = [1,2,3,4,5];
+ this.data.partner.records.push({id: 5, display_name: "partner 5", image: 'EEE'});
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday"> '+
+ '<field name="partner_ids" widget="many2many_tags_avatar" avatar_field="image" write_model="filter_partner" write_field="partner_id"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.containsN(calendar, '.o_calendar_filter_items .o_cw_filter_avatar', 3,
+ "should have 3 avatars in the side bar");
+
+ // Event 1
+ await testUtils.dom.click(calendar.$('.fc-event:first'));
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ assert.strictEqual(calendar.$('.o_cw_popover').find('img').length, 1, "should have 1 avatar");
+
+ // Event 2
+ await testUtils.dom.click(calendar.$('.fc-event:eq(1)'));
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ assert.strictEqual(calendar.$('.o_cw_popover').find('img').length, 5, "should have 5 avatar");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('open form view', async function (assert) {
+ assert.expect(3);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "get_formview_id") {
+ return Promise.resolve('A view');
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // click on an existing event to open the form view
+
+ testUtils.mock.intercept(calendar, 'do_action', function (event) {
+ assert.deepEqual(event.data.action,
+ {
+ type: "ir.actions.act_window",
+ res_id: 4,
+ res_model: "event",
+ views: [['A view', "form"]],
+ target: "current",
+ context: {}
+ },
+ "should open the form view");
+ });
+ await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content'));
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit'));
+
+ // create a new event and edit it
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ testUtils.fields.editInput($('.modal-body input:first'), 'coucou');
+
+ testUtils.mock.intercept(calendar, 'do_action', function (event) {
+ assert.deepEqual(event.data.action,
+ {
+ type: "ir.actions.act_window",
+ res_model: "event",
+ views: [[false, "form"]],
+ target: "current",
+ context: {
+ "default_name": "coucou",
+ "default_start": "2016-12-27 00:00:00",
+ "default_stop": "2016-12-27 00:00:00",
+ "default_allday": true
+ }
+ },
+ "should open the form view with the context default values");
+ });
+
+ testUtils.dom.click($('.modal button.btn:contains(Edit)'));
+
+ calendar.destroy();
+
+ assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body");
+ });
+
+ QUnit.test('create and edit event in month mode (all_day: false)', async function (assert) {
+ assert.expect(2);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return -240;
+ },
+ },
+ });
+
+ // create a new event and edit it
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ await testUtils.fields.editInput($('.modal-body input:first'), 'coucou');
+
+ testUtils.mock.intercept(calendar, 'do_action', function (event) {
+ assert.deepEqual(event.data.action,
+ {
+ type: "ir.actions.act_window",
+ res_model: "event",
+ views: [[false, "form"]],
+ target: "current",
+ context: {
+ "default_name": "coucou",
+ "default_start": "2016-12-27 11:00:00", // 7:00 + 4h
+ "default_stop": "2016-12-27 23:00:00", // 19:00 + 4h
+ }
+ },
+ "should open the form view with the context default values");
+ });
+
+ await testUtils.dom.click($('.modal button.btn:contains(Edit)'));
+
+ calendar.destroy();
+ assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body");
+ });
+
+ QUnit.test('show start time of single day event for month mode', async function (assert) {
+ assert.expect(4);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" ' +
+ 'string="Events" ' +
+ 'date_start="start" ' +
+ 'date_stop="stop" ' +
+ 'all_day="allday" ' +
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return -240;
+ },
+ },
+ });
+
+ assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "06:55",
+ "should have a correct time 06:55 AM in month mode");
+ assert.strictEqual(calendar.$('.fc-event:contains(event 4) .fc-content .fc-time').text(), "",
+ "should not display a time for all day event");
+ assert.strictEqual(calendar.$('.fc-event:contains(event 5) .fc-content .fc-time').text(), "",
+ "should not display a time for multiple days event");
+ // switch to week mode
+ await testUtils.dom.click(calendar.$('.o_calendar_button_week'));
+ assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "",
+ "should not show time in week mode as week mode already have time on y-axis");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('start time should not shown for date type field', async function (assert) {
+ assert.expect(1);
+
+ this.data.event.fields.start.type = "date";
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" ' +
+ 'string="Events" ' +
+ 'date_start="start" ' +
+ 'date_stop="stop" ' +
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return -240;
+ },
+ },
+ });
+
+ assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "",
+ "should not show time for date type field");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('start time should not shown in month mode if hide_time is true', async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" ' +
+ 'string="Events" ' +
+ 'date_start="start" ' +
+ 'date_stop="stop" ' +
+ 'hide_time="True" ' +
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return -240;
+ },
+ },
+ });
+
+ assert.strictEqual(calendar.$('.fc-event:contains(event 2) .fc-content .fc-time').text(), "",
+ "should not show time for hide_time attribute");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('readonly date_start field', async function (assert) {
+ assert.expect(4);
+
+ this.data.event.fields.start.readonly = true;
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "get_formview_id") {
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.containsNone(calendar, '.fc-resizer', "should not have resize button");
+
+ // click on an existing event to open the form view
+
+ testUtils.mock.intercept(calendar, 'do_action', function (event) {
+ assert.deepEqual(event.data.action,
+ {
+ type: "ir.actions.act_window",
+ res_id: 4,
+ res_model: "event",
+ views: [[false, "form"]],
+ target: "current",
+ context: {}
+ },
+ "should open the form view");
+ });
+ await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content'));
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit'));
+
+ // create a new event and edit it
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ await testUtils.fields.editInput($('.modal-body input:first'), 'coucou');
+
+ testUtils.mock.intercept(calendar, 'do_action', function (event) {
+ assert.deepEqual(event.data.action,
+ {
+ type: "ir.actions.act_window",
+ res_model: "event",
+ views: [[false, "form"]],
+ target: "current",
+ context: {
+ "default_name": "coucou",
+ "default_start": "2016-12-27 00:00:00",
+ "default_stop": "2016-12-27 00:00:00",
+ "default_allday": true
+ }
+ },
+ "should open the form view with the context default values");
+ });
+
+ await testUtils.dom.click($('.modal button.btn:contains(Edit)'));
+
+ calendar.destroy();
+
+ assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body");
+ });
+
+ QUnit.test('"all" filter', async function (assert) {
+ assert.expect(6);
+
+ var interval = [
+ ["start", "<=", "2016-12-17 23:59:59"],
+ ["stop", ">=", "2016-12-11 00:00:00"],
+ ];
+
+ var domains = [
+ interval.concat([["partner_ids", "in", [2,1]]]),
+ interval.concat([["partner_ids", "in", [1]]]),
+ interval,
+ ];
+
+ var i = 0;
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '</calendar>',
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, domains[i]);
+ i++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(calendar, '.fc-event', 9,
+ "should display 9 events on the week");
+
+ // Select the events only associated with partner 2
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-id=2] input'));
+ assert.containsN(calendar, '.fc-event', 4,
+ "should display 4 events on the week");
+
+ // Click on the 'all' filter to reload all events
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=all] input'));
+ assert.containsN(calendar, '.fc-event', 9,
+ "should display 9 events on the week");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('Add filters and specific color', async function (assert) {
+ assert.expect(5);
+
+ this.data.event.records.push(
+ {id: 8, user_id: 4, partner_id: 1, name: "event 8", start: "2016-12-11 09:00:00", stop: "2016-12-11 10:00:00", allday: false, partner_ids: [1,2,3], event_type_id: 3, color: 4},
+ {id: 9, user_id: 4, partner_id: 1, name: "event 9", start: "2016-12-11 19:00:00", stop: "2016-12-11 20:00:00", allday: false, partner_ids: [1,2,3], event_type_id: 1, color: 1},
+ );
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'color="color">'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '<field name="event_type_id" filters="1" color="color"/>'+
+ '</calendar>',
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.containsN(calendar, '.o_calendar_filter', 2, "should display 2 filters");
+
+ var $typeFilter = calendar.$('.o_calendar_filter:has(h5:contains(Event_Type))');
+ assert.ok($typeFilter.length, "should display 'Event Type' filter");
+ assert.containsN($typeFilter, '.o_calendar_filter_item', 3, "should display 3 filter items for 'Event Type'");
+
+ assert.containsOnce($typeFilter, '.o_calendar_filter_item[data-value=3].o_cw_filter_color_4', "Filter for event type 3 must have the color 4");
+
+ assert.containsOnce(calendar, '.fc-event[data-event-id=8].o_calendar_color_4', "Event of event type 3 must have the color 4");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create event with filters', async function (assert) {
+ assert.expect(7);
+
+ this.data.event.fields.user_id.default = 5;
+ this.data.event.fields.partner_id.default = 3;
+ this.data.user.records.push({id: 5, display_name: "user 5", partner_id: 3});
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '<field name="partner_id" filters="1" invisible="1"/>'+
+ '</calendar>',
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ }, {positionalClicks: true});
+
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 5, "should display 5 filter items");
+ assert.containsN(calendar, '.fc-event', 3, "should display 3 events");
+
+ // quick create a record
+ var left = calendar.$('.fc-bg td:eq(4)').offset().left+15;
+ var top = calendar.$('.fc-slats tr:eq(12) td:first').offset().top+15;
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mouseup");
+ await testUtils.nextTick();
+
+ await testUtils.fields.editInput($('.modal-body input:first'), 'coucou');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Create)'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 6, "should add the missing filter (active)");
+ assert.containsN(calendar, '.fc-event', 4, "should display the created item");
+ await testUtils.nextTick();
+
+ // change default value for quick create an hide record
+ this.data.event.fields.user_id.default = 4;
+ this.data.event.fields.partner_id.default = 4;
+
+ // quick create and other record
+ left = calendar.$('.fc-bg td:eq(3)').offset().left+15;
+ top = calendar.$('.fc-slats tr:eq(12) td:first').offset().top+15;
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mouseup");
+ await testUtils.nextTick();
+
+ testUtils.fields.editInput($('.modal-body input:first'), 'coucou 2');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Create)'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 6, "should have the same filters");
+ assert.containsN(calendar, '.fc-event', 4, "should not display the created item");
+
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input'));
+
+ assert.containsN(calendar, '.fc-event', 11, "should display all records");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('create event with filters (no quickCreate)', async function (assert) {
+ assert.expect(4);
+
+ this.data.event.fields.user_id.default = 5;
+ this.data.event.fields.partner_id.default = 3;
+ this.data.user.records.push({
+ id: 5,
+ display_name: "user 5",
+ partner_id: 3
+ });
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '<field name="partner_id" filters="1" invisible="1"/>'+
+ '</calendar>',
+ archs: {
+ "event,false,form":
+ '<form>'+
+ '<group>'+
+ '<field name="name"/>'+
+ '<field name="start"/>'+
+ '<field name="stop"/>'+
+ '<field name="user_id"/>'+
+ '<field name="partner_id" invisible="1"/>'+
+ '</group>'+
+ '</form>',
+ },
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ }, {positionalClicks: true});
+
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 5, "should display 5 filter items");
+ assert.containsN(calendar, '.fc-event', 3, "should display 3 events");
+ await testUtils.nextTick();
+
+ // quick create a record
+ var left = calendar.$('.fc-bg td:eq(4)').offset().left+15;
+ var top = calendar.$('.fc-slats tr:eq(12) td:first').offset().top+15;
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mousemove");
+ testUtils.dom.triggerPositionalMouseEvent(left, top + 200, "mouseup");
+ await testUtils.nextTick();
+
+ await testUtils.fields.editInput($('.modal-body input:first'), 'coucou');
+
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Edit)'));
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Save)'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 6, "should add the missing filter (active)");
+ assert.containsN(calendar, '.fc-event', 4, "should display the created item");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('Update event with filters', async function (assert) {
+ assert.expect(6);
+
+ var records = this.data.user.records;
+ records.push({
+ id: 5,
+ display_name: "user 5",
+ partner_id: 3
+ });
+
+ this.data.event.onchanges = {
+ user_id: function (obj) {
+ obj.partner_id = _.findWhere(records, {id:obj.user_id}).partner_id;
+ }
+ };
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '<field name="partner_id" filters="1" invisible="1"/>'+
+ '</calendar>',
+ archs: {
+ "event,false,form":
+ '<form>'+
+ '<group>'+
+ '<field name="name"/>'+
+ '<field name="start"/>'+
+ '<field name="stop"/>'+
+ '<field name="user_id"/>'+
+ '<field name="partner_ids" widget="many2many_tags"/>'+
+ '</group>'+
+ '</form>',
+ },
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 5, "should display 5 filter items");
+ assert.containsN(calendar, '.fc-event', 3, "should display 3 events");
+
+ await testUtils.dom.click(calendar.$('.fc-event:contains(event 2) .fc-content'));
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit'));
+ assert.strictEqual($('.modal .modal-title').text(), 'Open: event 2', "dialog should have a valid title");
+ await testUtils.dom.click($('.modal .o_field_widget[name="user_id"] input'));
+ await testUtils.dom.click($('.ui-menu-item a:contains(user 5)').trigger('mouseenter'));
+ await testUtils.dom.click($('.modal button.btn:contains(Save)'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 6, "should add the missing filter (active)");
+ assert.containsN(calendar, '.fc-event', 3, "should display the updated item");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('change pager with filters', async function (assert) {
+ assert.expect(3);
+
+ this.data.user.records.push({
+ id: 5,
+ display_name: "user 5",
+ partner_id: 3
+ });
+ this.data.event.records.push({
+ id: 8,
+ user_id: 5,
+ partner_id: 3,
+ name: "event 8",
+ start: "2016-12-06 04:00:00",
+ stop: "2016-12-06 08:00:00",
+ allday: false,
+ partner_ids: [1,2,3],
+ type: 1
+ }, {
+ id: 9,
+ user_id: session.uid,
+ partner_id: 1,
+ name: "event 9",
+ start: "2016-12-07 04:00:00",
+ stop: "2016-12-07 08:00:00",
+ allday: false,
+ partner_ids: [1,2,3],
+ type: 1
+ },{
+ id: 10,
+ user_id: 4,
+ partner_id: 4,
+ name: "event 10",
+ start: "2016-12-08 04:00:00",
+ stop: "2016-12-08 08:00:00",
+ allday: false,
+ partner_ids: [1,2,3],
+ type: 1
+ });
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '<field name="partner_id" filters="1" invisible="1"/>'+
+ '</calendar>',
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=4] input'));
+ await testUtils.dom.click($('.o_calendar_button_prev'));
+
+ assert.containsN(calendar, '.o_calendar_filter_item', 6, "should display 6 filter items");
+ assert.containsN(calendar, '.fc-event', 2, "should display 2 events");
+ assert.strictEqual(calendar.$('.fc-event .o_event_title').text().replace(/\s/g, ''), "event8event9",
+ "should display 2 events");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('ensure events are still shown if filters give an empty domain', async function (assert) {
+ assert.expect(2);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar mode="week" date_start="start">' +
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>' +
+ '</calendar>',
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.containsN(calendar, '.fc-event', 5,
+ "should display 5 events");
+ await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-value=all] input[type=checkbox]'));
+ assert.containsN(calendar, '.fc-event', 5,
+ "should display 5 events");
+ calendar.destroy();
+ });
+
+ QUnit.test('events starting at midnight', async function (assert) {
+ assert.expect(3);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar mode="week" date_start="start"/>',
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ translateParameters: { // Avoid issues due to localization formats
+ time_format: "%H:%M:%S",
+ },
+ }, {positionalClicks: true});
+
+ // Reset the scroll to 0 as we want to create an event from midnight
+ assert.ok(calendar.$('.fc-scroller')[0].scrollTop > 0,
+ "should scroll to 6:00 by default (this is true at least for resolutions up to 1900x1600)");
+ calendar.$('.fc-scroller')[0].scrollTop = 0;
+
+ // Click on Tuesday 12am
+ var top = calendar.$('.fc-axis:contains(0:00)').offset().top + 5;
+ var left = calendar.$('.fc-day:eq(2)').offset().left + 5;
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mouseup");
+ await testUtils.nextTick();
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test failed to simulate a click on the screen.' +
+ 'Your screen is probably too small or your dev tools are open.');
+ }
+ assert.ok($('.modal-dialog.modal-sm').length,
+ "should open the quick create dialog");
+
+ // Creating the event
+ testUtils.fields.editInput($('.modal-body input:first'), 'new event in quick create');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Create)'));
+ assert.strictEqual(calendar.$('.fc-event:contains(new event in quick create)').length, 1,
+ "should display the new record after quick create dialog");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('set event as all day when field is date', async function (assert) {
+ assert.expect(2);
+
+ this.data.event.records[0].start_date = "2016-12-14";
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start_date" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return -480;
+ }
+ },
+ });
+ assert.containsOnce(calendar, '.fc-day-grid .fc-event-container',
+ "should be one event in the all day row");
+ assert.strictEqual(moment(calendar.model.data.data[0].r_start).date(), 14,
+ "the date should be 14");
+ calendar.destroy();
+ });
+
+ QUnit.test('set event as all day when field is date (without all_day mapping)', async function (assert) {
+ assert.expect(1);
+
+ this.data.event.records[0].start_date = "2016-12-14";
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: `<calendar date_start="start_date" mode="week"></calendar>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+ assert.containsOnce(calendar, '.fc-day-grid .fc-event-container',
+ "should be one event in the all day row");
+ calendar.destroy();
+ });
+
+ QUnit.test('quickcreate avoid double event creation', async function (assert) {
+ assert.expect(1);
+ var createCount = 0;
+ var prom = testUtils.makeTestPromise();
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ var result = this._super(route, args);
+ if (args.method === "create") {
+ createCount++;
+ return prom.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // create a new event
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ var $input = $('.modal input:first');
+ await testUtils.fields.editInput($input, 'new event in quick create');
+ // Simulate ENTER pressed on Create button (after a TAB)
+ $input.trigger($.Event('keyup', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ await testUtils.nextTick();
+ await testUtils.dom.click($('.modal-footer button:first'));
+ prom.resolve();
+ await testUtils.nextTick();
+ assert.strictEqual(createCount, 1,
+ "should create only one event");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('check if the view destroys all widgets and instances', async function (assert) {
+ assert.expect(2);
+
+ var instanceNumber = 0;
+ testUtils.mock.patch(mixins.ParentedMixin, {
+ init: function () {
+ instanceNumber++;
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ if (!this.isDestroyed()) {
+ instanceNumber--;
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ var params = {
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start_date" '+
+ 'all_day="allday" '+
+ 'mode="week" '+
+ 'attendee="partner_ids" '+
+ 'color="partner_id">'+
+ '<filter name="user_id" avatar_field="image"/>'+
+ '<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ };
+
+ var calendar = await createCalendarView(params);
+ assert.ok(instanceNumber > 0);
+
+ calendar.destroy();
+ assert.strictEqual(instanceNumber, 0);
+
+ testUtils.mock.unpatch(mixins.ParentedMixin);
+ });
+
+ QUnit.test('create an event (async dialog) [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(3);
+
+ var prom = testUtils.makeTestPromise();
+ testUtils.mock.patch(Dialog, {
+ open: function () {
+ var _super = this._super.bind(this);
+ prom.then(_super);
+ return this;
+ },
+ });
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ // create an event
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ assert.strictEqual($('.modal').length, 0,
+ "should not have opened the dialog yet");
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.strictEqual($('.modal').length, 1,
+ "should have opened the dialog");
+ assert.strictEqual($('.modal input')[0], document.activeElement,
+ "should focus the input in the dialog");
+
+ calendar.destroy();
+ testUtils.mock.unpatch(Dialog);
+ });
+
+ QUnit.test('calendar is configured to have no groupBy menu', async function (assert) {
+ assert.expect(1);
+
+ var archs = {
+ 'event,1,calendar': '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'all_day="allday"/>',
+ 'event,false,search': '<search></search>',
+ };
+
+ var actions = [{
+ id: 1,
+ name: 'some action',
+ res_model: 'event',
+ type: 'ir.actions.act_window',
+ views: [[1, 'calendar']]
+ }];
+
+ var actionManager = await createActionManager({
+ actions: actions,
+ archs: archs,
+ data: this.data,
+ });
+
+ await actionManager.doAction(1);
+ assert.containsNone(actionManager.$('.o_control_panel .o_search_options span.fa.fa-bars'),
+ "the control panel has no groupBy menu");
+ actionManager.destroy();
+ });
+
+ QUnit.test('timezone does not affect current day', async function (assert) {
+ assert.expect(2);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar date_start="start_date"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return -2400; // 40 hours timezone
+ },
+ },
+
+ });
+
+ var $sidebar = calendar.$('.o_calendar_sidebar');
+
+ assert.strictEqual($sidebar.find('.ui-datepicker-current-day').text(), "12", "should highlight the target day");
+
+ // go to previous day
+ await testUtils.dom.click($sidebar.find('.ui-datepicker-current-day').prev());
+
+ assert.strictEqual($sidebar.find('.ui-datepicker-current-day').text(), "11", "should highlight the selected day");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('timezone does not affect drag and drop', async function (assert) {
+ assert.expect(10);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar date_start="start" mode="month">'+
+ '<field name="name"/>'+
+ '<field name="start"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "write") {
+ assert.deepEqual(args.args[0], [6], "event 6 is moved")
+ assert.deepEqual(args.args[1].start, "2016-11-29 08:00:00",
+ "event moved to 27th nov 16h00 +40 hours timezone")
+ }
+ return this._super(route, args);
+ },
+ session: {
+ getTZOffset: function () {
+ return -2400; // 40 hours timezone
+ },
+ },
+ });
+
+ assert.strictEqual(calendar.$('.fc-event:eq(0)').text().replace(/\s/g, ''), "08:00event1");
+ await testUtils.dom.click(calendar.$('.fc-event:eq(0)'));
+ assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "12/09/2016 08:00:00");
+
+ assert.strictEqual(calendar.$('.fc-event:eq(5)').text().replace(/\s/g, ''), "16:00event6");
+ await testUtils.dom.click(calendar.$('.fc-event:eq(5)'));
+ assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "12/16/2016 16:00:00");
+
+ // Move event 6 as on first day of month view (27th november 2016)
+ await testUtils.dragAndDrop(
+ calendar.$('.fc-event').eq(5),
+ calendar.$('.fc-day-top').first()
+ );
+ await testUtils.nextTick();
+
+ assert.strictEqual(calendar.$('.fc-event:eq(0)').text().replace(/\s/g, ''), "16:00event6");
+ await testUtils.dom.click(calendar.$('.fc-event:eq(0)'));
+ assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "11/27/2016 16:00:00");
+
+ assert.strictEqual(calendar.$('.fc-event:eq(1)').text().replace(/\s/g, ''), "08:00event1");
+ await testUtils.dom.click(calendar.$('.fc-event:eq(1)'));
+ assert.strictEqual(calendar.$('.o_field_widget[name="start"]').text(), "12/09/2016 08:00:00");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('timzeone does not affect calendar with date field', async function (assert) {
+ assert.expect(11);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar date_start="start_date" mode="month">'+
+ '<field name="name"/>'+
+ '<field name="start_date"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ assert.strictEqual(args.args[0].start_date, "2016-12-20 00:00:00");
+ }
+ if (args.method === "write") {
+ assert.step(args.args[1].start_date);
+ }
+ return this._super(route, args);
+ },
+ session: {
+ getTZOffset: function () {
+ return 120; // 2 hours timezone
+ },
+ },
+ });
+
+ // Create event (on 20 december)
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)');
+ await testUtils.triggerMouseEvent($cell, "mousedown");
+ await testUtils.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ var $input = $('.modal-body input:first');
+ await testUtils.fields.editInput($input, "An event");
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ await testUtils.nextTick();
+
+ await testUtils.dom.click(calendar.$('.fc-event:contains(An event)'));
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_field_date').text(), '12/20/2016', "should have correct start date");
+
+ // Move event to another day (on 27 november)
+ await testUtils.dragAndDrop(
+ calendar.$('.fc-event').first(),
+ calendar.$('.fc-day-top').first()
+ );
+ await testUtils.nextTick();
+ assert.verifySteps(["2016-11-27 00:00:00"]);
+ await testUtils.dom.click(calendar.$('.fc-event:contains(An event)'));
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_field_date').text(), '11/27/2016', "should have correct start date");
+
+ // Move event to last day (on 7 january)
+ await testUtils.dragAndDrop(
+ calendar.$('.fc-event').first(),
+ calendar.$('.fc-day-top').last()
+ );
+ await testUtils.nextTick();
+ assert.verifySteps(["2017-01-07 00:00:00"]);
+ await testUtils.dom.click(calendar.$('.fc-event:contains(An event)'));
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ assert.strictEqual(calendar.$('.o_cw_popover .o_cw_popover_fields_secondary .list-group-item:last .o_field_date').text(), '01/07/2017', "should have correct start date");
+ calendar.destroy();
+ });
+
+ QUnit.test("drag and drop on month mode", async function (assert) {
+ assert.expect(3);
+
+ const calendar = await createCalendarView({
+ arch:
+ `<calendar date_start="start" date_stop="stop" mode="month" event_open_popup="true" quick_add="False">
+ <field name="name"/>
+ <field name="partner_id"/>
+ </calendar>`,
+ archs: archs,
+ data: this.data,
+ model: 'event',
+ View: CalendarView,
+ viewOptions: { initialDate: initialDate },
+ });
+
+ // Create event (on 20 december)
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)');
+ testUtils.triggerMouseEvent($cell, "mousedown");
+ testUtils.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ var $input = $('.modal-body input:first');
+ await testUtils.fields.editInput($input, "An event");
+ await testUtils.dom.click($('.modal button.btn-primary'));
+ await testUtils.nextTick();
+
+ await testUtils.dragAndDrop(
+ calendar.$('.fc-event:contains("event 1")'),
+ calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day-top:eq(1)'),
+ { disableDrop: true },
+ );
+ assert.hasClass(calendar.$('.o_calendar_widget > [data-event-id="1"]'), 'dayGridMonth',
+ "should have dayGridMonth class");
+
+ // Move event to another day (on 19 december)
+ await testUtils.dragAndDrop(
+ calendar.$('.fc-event:contains("An event")'),
+ calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day-top:eq(1)')
+ );
+ await testUtils.nextTick();
+ await testUtils.dom.click(calendar.$('.fc-event:contains("An event")'));
+
+ assert.containsOnce(calendar, '.popover:contains("07:00")',
+ "start hour shouldn't have been changed");
+ assert.containsOnce(calendar, '.popover:contains("19:00")',
+ "end hour shouldn't have been changed");
+
+ calendar.destroy();
+ });
+
+ QUnit.test("drag and drop on month mode with all_day mapping", async function (assert) {
+ // Same test as before but in calendarEventToRecord (calendar_model.js) there is
+ // different condition branching with all_day mapping or not
+ assert.expect(2);
+
+ const calendar = await createCalendarView({
+ arch:
+ `<calendar date_start="start" date_stop="stop" mode="month" event_open_popup="true" quick_add="False" all_day="allday">
+ <field name="name"/>
+ <field name="partner_id"/>
+ </calendar>`,
+ archs: archs,
+ data: this.data,
+ model: 'event',
+ View: CalendarView,
+ viewOptions: { initialDate: initialDate },
+ });
+
+ // Create event (on 20 december)
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)');
+ testUtils.triggerMouseEvent($cell, "mousedown");
+ testUtils.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ var $input = $('.modal-body input:first');
+ await testUtils.fields.editInput($input, "An event");
+ await testUtils.dom.click($('.o_field_widget[name="allday"] input'));
+ await testUtils.nextTick();
+
+ // use datepicker to enter a date: 12/20/2016 07:00:00
+ testUtils.dom.openDatepicker($('.o_field_widget[name="start"].o_datepicker'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(07)'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]'));
+
+ // use datepicker to enter a date: 12/20/2016 19:00:00
+ testUtils.dom.openDatepicker($('.o_field_widget[name="stop"].o_datepicker'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker .timepicker-hour'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hours td.hour:contains(19)'));
+ await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]'));
+
+ await testUtils.dom.click($('.modal button.btn-primary'));
+ await testUtils.nextTick();
+
+ // Move event to another day (on 19 december)
+ await testUtils.dom.dragAndDrop(
+ calendar.$('.fc-event:contains("An event")'),
+ calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day-top:eq(1)')
+ );
+ await testUtils.nextTick();
+ await testUtils.dom.click(calendar.$('.fc-event:contains("An event")'));
+
+ assert.containsOnce(calendar, '.popover:contains("07:00")',
+ "start hour shouldn't have been changed");
+ assert.containsOnce(calendar, '.popover:contains("19:00")',
+ "end hour shouldn't have been changed");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('drag and drop on month mode with date_start and date_delay', async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar date_start="start" date_delay="delay" mode="month">'+
+ '<field name="name"/>'+
+ '<field name="start"/>'+
+ '<field name="delay"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "write") {
+ // delay should not be written at drag and drop
+ assert.equal(args.args[1].delay, undefined)
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // Create event (on 20 december)
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)');
+ await testUtils.triggerMouseEvent($cell, "mousedown");
+ await testUtils.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ var $input = $('.modal-body input:first');
+ await testUtils.fields.editInput($input, "An event");
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ await testUtils.nextTick();
+
+ // Move event to another day (on 27 november)
+ await testUtils.dragAndDrop(
+ calendar.$('.fc-event').first(),
+ calendar.$('.fc-day-top').first()
+ );
+ await testUtils.nextTick();
+
+ calendar.destroy();
+ });
+
+ QUnit.test('form_view_id attribute works (for creating events)', async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month" '+
+ 'form_view_id="42"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ // we simulate here the case where a create call with just
+ // the field name fails. This is a normal flow, the server
+ // reject the create rpc (quick create), then the web client
+ // fall back to a form view. This happens typically when a
+ // model has required fields
+ return Promise.reject('None shall pass!');
+ }
+ return this._super(route, args);
+ },
+ intercepts: {
+ do_action: function (event) {
+ assert.strictEqual(event.data.action.views[0][0], 42,
+ "should do a do_action with view id 42");
+ },
+ },
+ });
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ await testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ await testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ var $input = $('.modal-body input:first');
+ await testUtils.fields.editInput($input, "It's just a fleshwound");
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ await testUtils.nextTick(); // wait a little before to finish the test
+ calendar.destroy();
+ });
+
+ QUnit.test('form_view_id attribute works with popup (for creating events)', async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month" '+
+ 'event_open_popup="true" ' +
+ 'quick_add="false" ' +
+ 'form_view_id="1">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "load_views") {
+ assert.strictEqual(args.kwargs.views[0][0], 1,
+ "should load view with id 1");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ await testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ await testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+ calendar.destroy();
+ });
+
+ QUnit.test('calendar fallback to form view id in action if necessary', async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ action: {views: [{viewID: 1, type: 'kanban'}, {viewID: 43, type: 'form'}]}
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ // we simulate here the case where a create call with just
+ // the field name fails. This is a normal flow, the server
+ // reject the create rpc (quick create), then the web client
+ // fall back to a form view. This happens typically when a
+ // model has required fields
+ return Promise.reject('None shall pass!');
+ }
+ return this._super(route, args);
+ },
+ intercepts: {
+ do_action: function (event) {
+ assert.strictEqual(event.data.action.views[0][0], 43,
+ "should do a do_action with view id 43");
+ },
+ },
+ });
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ var $input = $('.modal-body input:first');
+ await testUtils.fields.editInput($input, "It's just a fleshwound");
+ await testUtils.dom.click($('.modal button.btn:contains(Create)'));
+ calendar.destroy();
+ });
+
+ QUnit.test('fullcalendar initializes with right locale', async function (assert) {
+ assert.expect(1);
+
+ var initialLocale = moment.locale();
+ // This will set the locale to zz
+ moment.defineLocale('zz', {
+ longDateFormat: {
+ L: 'DD/MM/YYYY'
+ },
+ weekdaysShort: ["zz1.", "zz2.", "zz3.", "zz4.", "zz5.", "zz6.", "zz7."],
+ });
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ action: {views: [{viewID: 1, type: 'kanban'}, {viewID: 43, type: 'form'}]}
+ },
+
+ });
+
+ assert.strictEqual(calendar.$('.fc-day-header:first').text(), "zz1. 11",
+ 'The day should be in the given locale specific format');
+
+ moment.locale(initialLocale);
+
+ calendar.destroy();
+ });
+
+ QUnit.test('default week start (US) month mode', async function (assert) {
+ // if not given any option, default week start is on Sunday
+ assert.expect(8);
+
+ // 2019-09-12 08:00:00
+ var initDate = new Date(2019, 8, 12, 8, 0, 0);
+ initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month">'+
+ '</calendar>',
+ archs: archs,
+
+ viewOptions: {
+ initialDate: initDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, [
+ ["start","<=","2019-10-12 23:59:59"],
+ ["stop",">=","2019-09-01 00:00:00"]
+ ],
+ 'The domain to search events in should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Sunday",
+ "The first day of the week should be Sunday");
+ assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Saturday",
+ "The last day of the week should be Saturday");
+
+ var $firstDay = calendar.$('.fc-day-top').first();
+
+ assert.strictEqual($firstDay.find('.fc-week-number').text(), "36",
+ "The number of the week should be correct");
+ assert.strictEqual($firstDay.find('.fc-day-number').text(), "1",
+ "The first day of the week should be 2019-09-01");
+ assert.strictEqual($firstDay.data('date'), "2019-09-01",
+ "The first day of the week should be 2019-09-01");
+
+ var $lastDay = calendar.$('.fc-day-top').last();
+ assert.strictEqual($lastDay.text(), "12",
+ "The last day of the week should be 2019-10-12");
+ assert.strictEqual($lastDay.data('date'), "2019-10-12",
+ "The last day of the week should be 2019-10-12");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('European week start month mode', async function (assert) {
+ assert.expect(8);
+
+ // 2019-09-12 08:00:00
+ var initDate = new Date(2019, 8, 15, 8, 0, 0);
+ initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month">'+
+ '</calendar>',
+ archs: archs,
+
+ viewOptions: {
+ initialDate: initDate,
+ },
+ translateParameters: {
+ week_start: 1,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, [
+ ["start","<=","2019-10-06 23:59:59"],
+ ["stop",">=","2019-08-26 00:00:00"]
+ ],
+ 'The domain to search events in should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Monday",
+ "The first day of the week should be Monday");
+ assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sunday",
+ "The last day of the week should be Sunday");
+
+ var $firstDay = calendar.$('.fc-day-top').first();
+ assert.strictEqual($firstDay.find('.fc-week-number').text(), "35",
+ "The number of the week should be correct");
+ assert.strictEqual($firstDay.find('.fc-day-number').text(), "26",
+ "The first day of the week should be 2019-09-01");
+ assert.strictEqual($firstDay.data('date'), "2019-08-26",
+ "The first day of the week should be 2019-08-26");
+
+ var $lastDay = calendar.$('.fc-day-top').last();
+ assert.strictEqual($lastDay.text(), "6",
+ "The last day of the week should be 2019-10-06");
+ assert.strictEqual($lastDay.data('date'), "2019-10-06",
+ "The last day of the week should be 2019-10-06");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('Monday week start week mode', async function (assert) {
+ assert.expect(3);
+
+ // 2019-09-12 08:00:00
+ var initDate = new Date(2019, 8, 15, 8, 0, 0);
+ initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week">'+
+ '</calendar>',
+ archs: archs,
+
+ viewOptions: {
+ initialDate: initDate,
+ },
+ translateParameters: {
+ week_start: 1,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, [
+ ["start","<=","2019-09-15 23:59:59"],
+ ["stop",">=","2019-09-09 00:00:00"]
+ ],
+ 'The domain to search events in should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Mon 9",
+ "The first day of the week should be Monday the 9th");
+ assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Sun 15",
+ "The last day of the week should be Sunday the 15th");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('Saturday week start week mode', async function (assert) {
+ assert.expect(3);
+
+ // 2019-09-12 08:00:00
+ var initDate = new Date(2019, 8, 12, 8, 0, 0);
+ initDate = new Date(initDate.getTime() - initDate.getTimezoneOffset()*60*1000);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week">'+
+ '</calendar>',
+ archs: archs,
+
+ viewOptions: {
+ initialDate: initDate,
+ },
+ translateParameters: {
+ week_start: 6,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'event') {
+ assert.deepEqual(args.kwargs.domain, [
+ ["start","<=","2019-09-13 23:59:59"],
+ ["stop",">=","2019-09-07 00:00:00"]
+ ],
+ 'The domain to search events in should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Sat 7",
+ "The first day of the week should be Saturday the 7th");
+ assert.strictEqual(calendar.$('.fc-day-header').last().text(), "Fri 13",
+ "The last day of the week should be Friday the 13th");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('edit record and attempt to create a record with "create" attribute set to false', async function (assert) {
+ assert.expect(8);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" '+
+ 'event_open_popup="true" '+
+ 'create="false" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month"/>',
+ archs: archs,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {name: 'event 4 modified'}, "should update the record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ // editing existing events should still be possible
+ // click on an existing event to open the formViewDialog
+
+ await testUtils.dom.click(calendar.$('.fc-event:contains(event 4) .fc-content'));
+
+ assert.ok(calendar.$('.o_cw_popover').length, "should open a popover clicking on event");
+ assert.ok(calendar.$('.o_cw_popover .o_cw_popover_edit').length, "popover should have an edit button");
+ assert.ok(calendar.$('.o_cw_popover .o_cw_popover_delete').length, "popover should have a delete button");
+ assert.ok(calendar.$('.o_cw_popover .o_cw_popover_close').length, "popover should have a close button");
+
+ await testUtils.dom.click(calendar.$('.o_cw_popover .o_cw_popover_edit'));
+
+ assert.ok($('.modal-body').length, "should open the form view in dialog when click on edit");
+
+ await testUtils.fields.editInput($('.modal-body input:first'), 'event 4 modified');
+ await testUtils.dom.click($('.modal-footer button.btn:contains(Save)'));
+
+ assert.notOk($('.modal-body').length, "save button should close the modal");
+
+ // creating an event should not be possible
+ // attempt to create a new event with create set to false
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ assert.notOk($('.modal-sm').length, "shouldn't open a quick create dialog for creating a new event with create attribute set to false");
+
+ calendar.destroy();
+ });
+
+
+ QUnit.test('attempt to create record with "create" and "quick_add" attributes set to false', async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" '+
+ 'create="false" '+
+ 'event_open_popup="true" '+
+ 'quick_add="false" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month"/>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ // attempt to create a new event with create set to false
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(5) .fc-day:eq(2)');
+
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ await testUtils.nextTick();
+
+ assert.strictEqual($('.modal').length, 0, "shouldn't open a form view for creating a new event with create attribute set to false");
+
+ calendar.destroy();
+ });
+
+ QUnit.test('attempt to create multiples events and the same day and check the ordering on month view', async function (assert) {
+ assert.expect(3);
+ /*
+ This test aims to verify that the order of the event in month view is coherent with their start date.
+ */
+ var initDate = new Date(2020, 2, 12, 8, 0, 0); //12 of March
+ this.data.event.records = [
+ {id: 1, name: "Second event", start: "2020-03-12 05:00:00", stop: "2020-03-12 07:00:00", allday: false},
+ {id: 2, name: "First event", start: "2020-03-12 02:00:00", stop: "2020-03-12 03:00:00", allday: false},
+ {id: 3, name: "Third event", start: "2020-03-12 08:00:00", stop: "2020-03-12 09:00:00", allday: false},
+ ];
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: `<calendar date_start="start" date_stop="stop" all_day="allday" mode="month" />`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initDate,
+ },
+ });
+ assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length, "should display in the calendar"); // OK
+ // Testing the order of the events: by start date
+ assert.strictEqual(calendar.$('.o_event_title').length, 3, "3 events should be available"); // OK
+ assert.strictEqual(calendar.$('.o_event_title').first().text(), 'First event', "First event should be on top");
+ calendar.destroy();
+ });
+
+ QUnit.test("drag and drop 24h event on week mode", async function (assert) {
+ assert.expect(1);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: `
+ <calendar
+ event_open_popup="true"
+ quick_add="False"
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ mode="week"
+ />
+ `,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ }, {positionalClicks: true});
+
+ var top = calendar.$('.fc-axis:contains(8:00)').offset().top + 5;
+ var left = calendar.$('.fc-day:eq(2)').offset().left + 5;
+
+ try {
+ testUtils.dom.triggerPositionalMouseEvent(left, top, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+
+ top = calendar.$('.fc-axis:contains(8:00)').offset().top - 5;
+ var leftNextDay = calendar.$('.fc-day:eq(3)').offset().left + 5;
+ testUtils.dom.triggerPositionalMouseEvent(leftNextDay, top, "mousemove");
+ await testUtils.dom.triggerPositionalMouseEvent(leftNextDay, top, "mouseup");
+ await testUtils.nextTick();
+ assert.equal($('.o_field_boolean.o_field_widget[name=allday] input').is(':checked'), false,
+ "The event must not have the all_day active");
+ await testUtils.dom.click($('.modal button.btn:contains(Discard)'));
+
+ calendar.destroy();
+ });
+
+ QUnit.test('correctly display year view', async function (assert) {
+ assert.expect(27);
+
+ const calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: `
+ <calendar
+ create="false"
+ event_open_popup="true"
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ mode="year"
+ attendee="partner_ids"
+ color="partner_id"
+ >
+ <field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>
+ <field name="partner_id" filters="1" invisible="1"/>
+ </calendar>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ }, {positionalClicks: true});
+
+ // Check view
+ assert.containsN(calendar, '.fc-month', 12);
+ assert.strictEqual(
+ calendar.el.querySelector('.fc-month:first-child .fc-header-toolbar').textContent,
+ 'Jan 2016'
+ );
+ assert.containsN(calendar.el, '.fc-bgevent', 7,
+ 'There should be 6 events displayed but there is 1 split on 2 weeks');
+
+ async function clickDate(date) {
+ const el = calendar.el.querySelector(`.fc-day-top[data-date="${date}"]`);
+ el.scrollIntoView(); // scroll to it as the calendar could be too small
+
+ testUtils.dom.triggerMouseEvent(el, "mousedown");
+ testUtils.dom.triggerMouseEvent(el, "mouseup");
+
+ await testUtils.nextTick();
+ }
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-11-17"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-17');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-11-16"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-16');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ let popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText, 'November 14-16, 2016 event 7');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-11-14"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-14');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText, 'November 14-16, 2016 event 7');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-11-13"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-13');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-12-10"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-10');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-12-12"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-12');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText, 'December 12, 2016 event 2 event 3');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-12-14"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-14');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText,
+ 'December 14, 2016 event 4 December 13-20, 2016 event 5');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-12-21"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-21');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ calendar.destroy();
+ });
+
+ QUnit.test('toggle filters in year view', async function (assert) {
+ assert.expect(42);
+
+ const calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: `
+ <calendar
+ event_open_popup="true"
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ mode="year"
+ attendee="partner_ids"
+ color="partner_id"
+ >
+ <field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>
+ <field name="partner_id" filters="1" invisible="1"/>
+ '</calendar>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ function checkEvents(countMap) {
+ for (const [id, count] of Object.entries(countMap)) {
+ assert.containsN(calendar, `.fc-bgevent[data-event-id="${id}"]`, count);
+ }
+ }
+
+ checkEvents({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 2, 7: 1, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="2"] label'));
+ checkEvents({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="1"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 1, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="4"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="1"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="2"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="4"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 2, 7: 0, });
+
+ calendar.destroy();
+ });
+
+ QUnit.test('allowed scales', async function (assert) {
+ assert.expect(8);
+
+ let calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ `<calendar
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"/>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_day');
+ assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_week');
+ assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_month');
+ assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_year');
+
+ calendar.destroy();
+
+ calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ `<calendar
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ scales="day,week"/>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_day');
+ assert.containsOnce(calendar, '.o_calendar_scale_buttons .o_calendar_button_week');
+ assert.containsNone(calendar, '.o_calendar_scale_buttons .o_calendar_button_month');
+ assert.containsNone(calendar, '.o_calendar_scale_buttons .o_calendar_button_year');
+
+ calendar.destroy();
+ });
+
+ QUnit.test('click outside the popup should close it', async function (assert) {
+ assert.expect(4);
+
+ var calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ `<calendar
+ create="false"
+ event_open_popup="true"
+ quick_add="false"
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ mode="month"/>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ await testUtils.dom.click(calendar.el.querySelector('.fc-event .fc-content'));
+ assert.containsOnce(calendar, '.o_cw_popover',
+ 'open popup when click on event');
+
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_body'));
+ assert.containsOnce(calendar, '.o_cw_popover',
+ 'keep popup openned when click inside popup');
+
+ await testUtils.dom.click(calendar.el.querySelector('.o_content'));
+ assert.containsNone(calendar, '.o_cw_popover',
+ 'close popup when click outside popup');
+
+ calendar.destroy();
+ });
+
+ QUnit.test("fields are added in the right order in popover", async function (assert) {
+ assert.expect(3);
+
+ const def = testUtils.makeTestPromise();
+ const DeferredWidget = AbstractField.extend({
+ async start() {
+ await this._super(...arguments);
+ await def;
+ }
+ });
+ fieldRegistry.add("deferred_widget", DeferredWidget);
+
+ const calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ `<calendar
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ mode="month"
+ >
+ <field name="user_id" widget="deferred_widget" />
+ <field name="name" />
+ </calendar>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ await testUtils.dom.click(calendar.$(`[data-event-id="4"]`));
+ assert.containsNone(calendar, ".o_cw_popover");
+
+ def.resolve();
+ await testUtils.nextTick();
+ assert.containsOnce(calendar, ".o_cw_popover");
+
+ assert.strictEqual(
+ calendar.$(".o_cw_popover .o_cw_popover_fields_secondary").text(),
+ "user : name : event 4"
+ );
+
+ calendar.destroy();
+ delete fieldRegistry.map.deferred_widget;
+ });
+
+});
+
+});
diff --git a/addons/web/static/tests/views/form_benchmarks.js b/addons/web/static/tests/views/form_benchmarks.js
new file mode 100644
index 00000000..88913bcb
--- /dev/null
+++ b/addons/web/static/tests/views/form_benchmarks.js
@@ -0,0 +1,108 @@
+odoo.define('web.form_benchmarks', function (require) {
+ "use strict";
+
+ const FormView = require('web.FormView');
+ const testUtils = require('web.test_utils');
+
+ const { createView } = testUtils;
+
+ QUnit.module('Form View', {
+ beforeEach: function () {
+ this.data = {
+ foo: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ many2many: { string: "bar", type: "many2many", relation: 'bar'},
+ },
+ records: [
+ { id: 1, foo: "bar", many2many: []},
+ ],
+ onchanges: {}
+ },
+ bar: {
+ fields: {
+ char: {string: "char", type: "char"},
+ many2many: { string: "pokemon", type: "many2many", relation: 'pokemon'},
+ },
+ records: [],
+ onchanges: {}
+ },
+ pokemon: {
+ fields: {
+ name: {string: "Name", type: "char"},
+ },
+ records: [],
+ onchanges: {}
+ },
+ };
+ this.arch = null;
+ this.run = function (assert, viewParams, cb) {
+ const data = this.data;
+ const arch = this.arch;
+ return new Promise(resolve => {
+ new Benchmark.Suite({})
+ .add('form', {
+ defer: true,
+ fn: async (deferred) => {
+ const form = await createView(Object.assign({
+ View: FormView,
+ model: 'foo',
+ data,
+ arch,
+ }, viewParams));
+ if (cb) {
+ await cb(form);
+ }
+ form.destroy();
+ deferred.resolve();
+ },
+ })
+ .on('cycle', event => {
+ assert.ok(true, String(event.target));
+ })
+ .on('complete', resolve)
+ .run({ async: true });
+ });
+ };
+ }
+ }, function () {
+ QUnit.test('x2many with 250 rows, 2 fields (with many2many_tags, and modifiers), onchanges, and edition', function (assert) {
+ assert.expect(1);
+
+ this.data.foo.onchanges.many2many = function (obj) {
+ obj.many2many = [5].concat(obj.many2many);
+ };
+ for (let i = 2; i < 500; i++) {
+ this.data.bar.records.push({
+ id: i,
+ char: "automated data",
+ });
+ this.data.foo.records[0].many2many.push(i);
+ }
+ this.arch = `
+ <form>
+ <field name="many2many">
+ <tree editable="top" limit="250">
+ <field name="char"/>
+ <field name="many2many" widget="many2many_tags" attrs="{'readonly': [('char', '==', 'toto')]}"/>
+ </tree>
+ </field>
+ </form>`;
+ return this.run(assert, { res_id: 1 }, async form => {
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(form.$('input:first'), "tralala");
+ });
+ });
+
+ QUnit.test('form view with 100 fields, half of them being invisible', function (assert) {
+ assert.expect(1);
+
+ this.arch = `
+ <form>
+ ${[...Array(100)].map((_, i) => '<field name="foo"' + (i % 2 ? ' invisible="1"' : '') + '/>').join('')}
+ </form>`;
+ return this.run(assert);
+ });
+ });
+});
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
new file mode 100644
index 00000000..094d913f
--- /dev/null
+++ b/addons/web/static/tests/views/form_tests.js
@@ -0,0 +1,9907 @@
+odoo.define('web.form_tests', function (require) {
+"use strict";
+
+const AbstractField = require("web.AbstractField");
+var AbstractStorageService = require('web.AbstractStorageService');
+var BasicModel = require('web.BasicModel');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var fieldRegistry = require('web.field_registry');
+const fieldRegistryOwl = require('web.field_registry_owl');
+const FormRenderer = require('web.FormRenderer');
+var FormView = require('web.FormView');
+var mixins = require('web.mixins');
+var NotificationService = require('web.NotificationService');
+var pyUtils = require('web.py_utils');
+var RamStorage = require('web.RamStorage');
+var testUtils = require('web.test_utils');
+var widgetRegistry = require('web.widget_registry');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+const cpHelpers = testUtils.controlPanel;
+var createView = testUtils.createView;
+var createActionManager = testUtils.createActionManager;
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ foo: {string: "Foo", type: "char", default: "My little Foo Value"},
+ bar: {string: "Bar", type: "boolean"},
+ int_field: {string: "int_field", type: "integer", sortable: true},
+ qux: {string: "Qux", type: "float", digits: [16,1] },
+ p: {string: "one2many field", type: "one2many", relation: 'partner'},
+ trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
+ timmy: { string: "pokemon", type: "many2many", relation: 'partner_type'},
+ product_id: {string: "Product", type: "many2one", relation: 'product'},
+ priority: {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ },
+ state: {string: "State", type: "selection", selection: [["ab", "AB"], ["cd", "CD"], ["ef", "EF"]]},
+ date: {string: "Some Date", type: "date"},
+ datetime: {string: "Datetime Field", type: 'datetime'},
+ product_ids: {string: "one2many product", type: "one2many", relation: "product"},
+ reference: {string: "Reference Field", type: 'reference', selection: [["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]},
+ },
+ records: [{
+ id: 1,
+ display_name: "first record",
+ bar: true,
+ foo: "yop",
+ int_field: 10,
+ qux: 0.44,
+ p: [],
+ timmy: [],
+ trululu: 4,
+ state: "ab",
+ date: "2017-01-25",
+ datetime: "2016-12-12 10:55:05",
+ }, {
+ id: 2,
+ display_name: "second record",
+ bar: true,
+ foo: "blip",
+ int_field: 9,
+ qux: 13,
+ p: [],
+ timmy: [],
+ trululu: 1,
+ state: "cd",
+ }, {
+ id: 4,
+ display_name: "aaa",
+ state: "ef",
+ }, {
+ id: 5,
+ display_name: "aaa",
+ foo:'',
+ bar:false,
+ state: "ef",
+ }],
+ onchanges: {},
+ },
+ product: {
+ fields: {
+ display_name: {string: "Product Name", type: "char"},
+ name: {string: "Product Name", type: "char"},
+ partner_type_id: {string: "Partner type", type: "many2one", relation: "partner_type"},
+ },
+ records: [{
+ id: 37,
+ display_name: "xphone",
+ }, {
+ id: 41,
+ display_name: "xpad",
+ }]
+ },
+ partner_type: {
+ fields: {
+ name: {string: "Partner Type", type: "char"},
+ color: {string: "Color index", type: "integer"},
+ },
+ records: [
+ {id: 12, display_name: "gold", color: 2},
+ {id: 14, display_name: "silver", color: 5},
+ ]
+ },
+ "ir.translation": {
+ fields: {
+ lang_code: {type: "char"},
+ value: {type: "char"},
+ res_id: {type: "integer"}
+ },
+ records: [{
+ id: 99,
+ res_id: 12,
+ value: '',
+ lang_code: 'en_US'
+ }]
+ },
+ user: {
+ fields: {
+ name: {string: "Name", type: "char"},
+ partner_ids: {string: "one2many partners field", type: "one2many", relation: 'partner', relation_field: 'user_id'},
+ },
+ records: [{
+ id: 17,
+ name: "Aline",
+ partner_ids: [1],
+ }, {
+ id: 19,
+ name: "Christine",
+ }]
+ },
+ "res.company": {
+ fields: {
+ name: { string: "Name", type: "char" },
+ },
+ },
+ };
+ this.actions = [{
+ id: 1,
+ name: 'Partners Action 1',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'kanban'], [false, 'form']],
+ }];
+ },
+}, function () {
+
+ QUnit.module('FormView');
+
+ QUnit.test('simple form rendering', async function (assert) {
+ assert.expect(12);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<div class="test" style="opacity: 0.5;">some html<span>aa</span></div>' +
+ '<sheet>' +
+ '<group>' +
+ '<group style="background-color: red">' +
+ '<field name="foo" style="color: blue"/>' +
+ '<field name="bar"/>' +
+ '<field name="int_field" string="f3_description"/>' +
+ '<field name="qux"/>' +
+ '</group>' +
+ '<group>' +
+ '<div class="hello"></div>' +
+ '</group>' +
+ '</group>' +
+ '<notebook>' +
+ '<page string="Partner Yo">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+
+ assert.containsOnce(form, 'div.test');
+ assert.strictEqual(form.$('div.test').css('opacity'), '0.5',
+ "should keep the inline style on html elements");
+ assert.containsOnce(form, 'label:contains(Foo)');
+ assert.containsOnce(form, 'span:contains(blip)');
+ assert.hasAttrValue(form.$('.o_group .o_group:first'), 'style', 'background-color: red',
+ "should apply style attribute on groups");
+ assert.hasAttrValue(form.$('.o_field_widget[name=foo]'), 'style', 'color: blue',
+ "should apply style attribute on fields");
+ assert.containsNone(form, 'label:contains(something_id)');
+ assert.containsOnce(form, 'label:contains(f3_description)');
+ assert.containsOnce(form, 'div.o_field_one2many table');
+ assert.containsOnce(form, 'tbody td:not(.o_list_record_selector) .custom-checkbox input:checked');
+ assert.containsOnce(form, '.o_control_panel .breadcrumb:contains(second record)');
+ assert.containsNone(form, 'label.o_form_label_empty:contains(timmy)');
+
+ form.destroy();
+ });
+
+ QUnit.test('duplicate fields rendered properly', async function (assert) {
+ assert.expect(6);
+ this.data.partner.records.push({
+ id: 6,
+ bar: true,
+ foo: "blip",
+ int_field: 9,
+ });
+ var form = await createView({
+ View: FormView,
+ viewOptions: { mode: 'edit' },
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<group>' +
+ '<field name="foo" attrs="{\'invisible\': [(\'bar\',\'=\',True)]}"/>' +
+ '<field name="foo" attrs="{\'invisible\': [(\'bar\',\'=\',False)]}"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" attrs="{\'readonly\': [(\'bar\',\'=\',False)]}"/>' +
+ '<field name="int_field" attrs="{\'readonly\': [(\'bar\',\'=\',True)]}"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</group>' +
+ '</form>',
+ res_id: 6,
+ });
+
+ assert.hasClass(form.$('div.o_group input[name="foo"]:eq(0)'), 'o_invisible_modifier', 'first foo widget should be invisible');
+ assert.containsOnce(form, 'div.o_group input[name="foo"]:eq(1):not(.o_invisible_modifier)', "second foo widget should be visible");
+ assert.containsOnce(form, 'div.o_group input[name="foo"]:eq(2):not(.o_invisible_modifier)', "third foo widget should be visible");
+ await testUtils.fields.editInput(form.$('div.o_group input[name="foo"]:eq(2)'), "hello");
+ assert.strictEqual(form.$('div.o_group input[name="foo"]:eq(1)').val(), "hello", "second foo widget should be 'hello'");
+ assert.containsOnce(form, 'div.o_group input[name="int_field"]:eq(0):not(.o_readonly_modifier)', "first int_field widget should not be readonly");
+ assert.hasClass(form.$('div.o_group span[name="int_field"]:eq(0)'),'o_readonly_modifier', "second int_field widget should be readonly");
+ form.destroy();
+ });
+
+ QUnit.test('duplicate fields rendered properly (one2many)', async function (assert) {
+ assert.expect(7);
+ this.data.partner.records.push({
+ id: 6,
+ p: [1],
+ });
+ var form = await createView({
+ View: FormView,
+ viewOptions: { mode: 'edit' },
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<notebook>' +
+ '<page>' +
+ '<field name="p">' +
+ '<tree editable="True">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '<form/>' +
+ '</field>' +
+ '</page>' +
+ '<page>' +
+ '<field name="p" readonly="True">' +
+ '<tree editable="True">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '<form/>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</form>',
+ res_id: 6,
+ });
+ assert.containsOnce(form, 'div.o_field_one2many:eq(0):not(.o_readonly_modifier)', "first one2many widget should not be readonly");
+ assert.hasClass(form.$('div.o_field_one2many:eq(1)'),'o_readonly_modifier', "second one2many widget should be readonly");
+ await testUtils.dom.click(form.$('div.tab-content table.o_list_table:eq(0) tr.o_data_row td.o_data_cell:eq(0)'));
+ assert.strictEqual(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]').val(), "yop",
+ "first line in one2many of first tab contains yop");
+ assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(),
+ "yop", "first line in one2many of second tab contains yop");
+ await testUtils.fields.editInput(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]'), "hello");
+ assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row td.o_data_cell:eq(0)').text(), "hello",
+ "first line in one2many of second tab contains hello");
+ await testUtils.dom.click(form.$('div.tab-content table.o_list_table:eq(0) a:contains(Add a line)'));
+ assert.strictEqual(form.$('div.tab-content table.o_list_table tr.o_selected_row input[name="foo"]').val(), "My little Foo Value",
+ "second line in one2many of first tab contains 'My little Foo Value'");
+ assert.strictEqual(form.$('div.tab-content table.o_list_table:eq(1) tr.o_data_row:eq(1) td.o_data_cell:eq(0)').text(),
+ "My little Foo Value", "first line in one2many of second tab contains hello");
+ form.destroy();
+ });
+
+ QUnit.test('attributes are transferred on async widgets', async function (assert) {
+ assert.expect(1);
+ var done = assert.async();
+
+ var def = testUtils.makeTestPromise();
+
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return def;
+ },
+ }));
+
+ createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="foo" style="color: blue" widget="asyncwidget"/>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ }).then(function (form) {
+ assert.hasAttrValue(form.$('.o_field_widget[name=foo]'), 'style', 'color: blue',
+ "should apply style attribute on fields");
+ form.destroy();
+ delete fieldRegistry.map.asyncwidget;
+ done();
+ });
+ def.resolve();
+ await testUtils.nextTick();
+ });
+
+ QUnit.test('placeholder attribute on input', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<input placeholder="chimay"/>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.containsOnce(form, 'input[placeholder="chimay"]');
+ form.destroy();
+ });
+
+ QUnit.test('decoration works on widgets', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="display_name" decoration-danger="int_field &lt; 5"/>' +
+ '<field name="foo" decoration-danger="int_field &gt; 5"/>' +
+ '</form>',
+ res_id: 2,
+ });
+ assert.doesNotHaveClass(form.$('span[name="display_name"]'), 'text-danger');
+ assert.hasClass(form.$('span[name="foo"]'), 'text-danger');
+ form.destroy();
+ });
+
+ QUnit.test('decoration on widgets are reevaluated if necessary', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="display_name" decoration-danger="int_field &lt; 5"/>' +
+ '</form>',
+ res_id: 2,
+ viewOptions: {mode: 'edit'},
+ });
+ assert.doesNotHaveClass(form.$('input[name="display_name"]'), 'text-danger');
+ await testUtils.fields.editInput(form.$('input[name=int_field]'), 3);
+ assert.hasClass(form.$('input[name="display_name"]'), 'text-danger');
+ form.destroy();
+ });
+
+ QUnit.test('decoration on widgets works on same widget', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field" decoration-danger="int_field &lt; 5"/>' +
+ '</form>',
+ res_id: 2,
+ viewOptions: {mode: 'edit'},
+ });
+ assert.doesNotHaveClass(form.$('input[name="int_field"]'), 'text-danger');
+ await testUtils.fields.editInput(form.$('input[name=int_field]'), 3);
+ assert.hasClass(form.$('input[name="int_field"]'), 'text-danger');
+ form.destroy();
+ });
+
+ QUnit.test('only necessary fields are fetched with correct context', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ // NOTE: actually, the current web client always request the __last_update
+ // field, not sure why. Maybe this test should be modified.
+ assert.deepEqual(args.args[1], ["foo", "display_name"],
+ "should only fetch requested fields");
+ assert.deepEqual(args.kwargs.context, {bin_size: true},
+ "bin_size should always be in the context");
+ return this._super(route, args);
+ }
+ });
+ form.destroy();
+ });
+
+ QUnit.test('group rendering', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, 'table.o_inner_group');
+
+ form.destroy();
+ });
+
+ QUnit.test('group containing both a field and a group', async function (assert) {
+ // The purpose of this test is to check that classnames defined in a
+ // field widget and those added by the form renderer are correctly
+ // combined. For instance, the renderer adds className 'o_group_col_x'
+ // on outer group's children (an outer group being a group that contains
+ // at least a group).
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<group>' +
+ '<field name="int_field"/>' +
+ '</group>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, '.o_group .o_field_widget[name=foo]');
+ assert.containsOnce(form, '.o_group .o_inner_group .o_field_widget[name=int_field]');
+
+ assert.hasClass(form.$('.o_field_widget[name=foo]'), 'o_field_char');
+ assert.hasClass(form.$('.o_field_widget[name=foo]'), 'o_group_col_6');
+
+ form.destroy();
+ });
+
+ QUnit.test('Form and subview with _view_ref contexts', async function (assert) {
+ assert.expect(2);
+
+ this.data.product.fields.partner_type_ids = {string: "one2many field", type: "one2many", relation: "partner_type"},
+ this.data.product.records = [{id: 1, name: 'Tromblon', partner_type_ids: [12,14]}];
+ this.data.partner.records[0].product_id = 1;
+
+ var actionManager = await createActionManager({
+ data: this.data,
+ archs: {
+ 'product,false,form': '<form>'+
+ '<field name="name"/>'+
+ '<field name="partner_type_ids" context="{\'tree_view_ref\': \'some_other_tree_view\'}"/>' +
+ '</form>',
+
+ 'partner_type,false,list': '<tree>'+
+ '<field name="color"/>'+
+ '</tree>',
+ 'product,false,search': '<search></search>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'load_views') {
+ var context = args.kwargs.context;
+ if (args.model === 'product') {
+ assert.deepEqual(context, {tree_view_ref: 'some_tree_view'},
+ 'The correct _view_ref should have been sent to the server, first time');
+ }
+ if (args.model === 'partner_type') {
+ assert.deepEqual(context, {
+ base_model_name: 'product',
+ tree_view_ref: 'some_other_tree_view',
+ }, 'The correct _view_ref should have been sent to the server for the subview');
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="name"/>' +
+ '<field name="product_id" context="{\'tree_view_ref\': \'some_tree_view\'}"/>' +
+ '</form>',
+ res_id: 1,
+
+ mockRPC: function(route, args) {
+ if (args.method === 'get_formview_action') {
+ return Promise.resolve({
+ res_id: 1,
+ type: 'ir.actions.act_window',
+ target: 'current',
+ res_model: args.model,
+ context: args.kwargs.context,
+ 'view_mode': 'form',
+ 'views': [[false, 'form']],
+ });
+ }
+ return this._super(route, args);
+ },
+
+ interceptsPropagate: {
+ do_action: function (ev) {
+ actionManager.doAction(ev.data.action);
+ },
+ },
+ });
+ await testUtils.dom.click(form.$('.o_field_widget[name="product_id"]'));
+ form.destroy();
+ actionManager.destroy();
+ });
+
+ QUnit.test('invisible fields are properly hidden', async function (assert) {
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" invisible="1"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '<field name="qux" invisible="1"/>' +
+ // x2many field without inline view: as it is always invisible, the view
+ // should not be fetched. we don't specify any view in this test, so if it
+ // ever tries to fetch it, it will crash, indicating that this is wrong.
+ '<field name="p" invisible="True"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsNone(form, 'label:contains(Foo)');
+ assert.containsNone(form, '.o_field_widget[name=foo]');
+ assert.containsNone(form, '.o_field_widget[name=qux]');
+ assert.containsNone(form, '.o_field_widget[name=p]');
+
+ form.destroy();
+ });
+
+ QUnit.test('invisible elements are properly hidden', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header invisible="1">' +
+ '<button name="myaction" string="coucou"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<group string="invgroup" invisible="1">' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</group>' +
+ '<notebook>' +
+ '<page string="visible"/>' +
+ '<page string="invisible" invisible="1"/>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+ assert.containsOnce(form, '.o_form_statusbar.o_invisible_modifier button:contains(coucou)');
+ assert.containsOnce(form, '.o_notebook li.o_invisible_modifier a:contains(invisible)');
+ assert.containsOnce(form, 'table.o_inner_group.o_invisible_modifier td:contains(invgroup)');
+ form.destroy();
+ });
+
+ QUnit.test('invisible attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(3);
+
+ // we set the value bar to simulate a falsy boolean value.
+ this.data.partner.records[0].bar = false;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet><group>' +
+ '<field name="product_id"/>' +
+ '<field name="timmy" invisible="1"/>' +
+ '<field name="foo" class="foo_field" attrs=\'{"invisible": [["product_id", "=", false]]}\'/>' +
+ '<field name="bar" class="bar_field" attrs=\'{"invisible":[("bar","=",False),("timmy","=",[])]}\'/>' +
+ '</group></sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode:'edit'
+ },
+ });
+
+ assert.hasClass(form.$('.foo_field'), 'o_invisible_modifier');
+ assert.hasClass(form.$('.bar_field'), 'o_invisible_modifier');
+
+ // set a value on the m2o
+ await testUtils.fields.many2one.searchAndClickItem('product_id');
+ assert.doesNotHaveClass(form.$('.foo_field'), 'o_invisible_modifier');
+
+ form.destroy();
+ });
+
+ QUnit.test('asynchronous fields can be set invisible', async function (assert) {
+ assert.expect(1);
+ var done = assert.async();
+
+ var def = testUtils.makeTestPromise();
+
+ // we choose this widget because it is a quite simple widget with a non
+ // empty qweb template
+ var PercentPieWidget = fieldRegistry.get('percentpie');
+ fieldRegistry.add('asyncwidget', PercentPieWidget.extend({
+ willStart: function () {
+ return def;
+ },
+ }));
+
+ createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet><group>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" invisible="1" widget="asyncwidget"/>' +
+ '</group></sheet>' +
+ '</form>',
+ res_id: 1,
+ }).then(function (form) {
+ assert.containsNone(form, '.o_field_widget[name="int_field"]');
+ form.destroy();
+ delete fieldRegistry.map.asyncwidget;
+ done();
+ });
+ def.resolve();
+ });
+
+ QUnit.test('properly handle modifiers and attributes on notebook tags', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id"/>' +
+ '<notebook class="new_class" attrs=\'{"invisible": [["product_id", "=", false]]}\'>' +
+ '<page string="Foo">' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.hasClass(form.$('.o_notebook'), 'o_invisible_modifier');
+ assert.hasClass(form.$('.o_notebook'), 'new_class');
+ form.destroy();
+ });
+
+ QUnit.test('empty notebook', async function (assert) {
+ assert.expect(2);
+
+ const form = await createView({
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <notebook/>
+ </sheet>
+ </form>`,
+ data: this.data,
+ model: 'partner',
+ res_id: 1,
+ View: FormView,
+ });
+
+ // Does not change when switching state
+ await testUtils.form.clickEdit(form);
+
+ assert.containsNone(form, ':scope .o_notebook .nav');
+
+ // Does not change when coming back to initial state
+ await testUtils.form.clickSave(form);
+
+ assert.containsNone(form, ':scope .o_notebook .nav');
+
+ form.destroy();
+ });
+
+ QUnit.test('no visible page', async function (assert) {
+ assert.expect(4);
+
+ const form = await createView({
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <notebook>
+ <page string="Foo" invisible="1">
+ <field name="foo"/>
+ </page>
+ <page string="Bar" invisible="1">
+ <field name="bar"/>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ data: this.data,
+ model: 'partner',
+ res_id: 1,
+ View: FormView,
+ });
+
+ // Does not change when switching state
+ await testUtils.form.clickEdit(form);
+
+ for (const nav of form.el.querySelectorAll(':scope .o_notebook .nav')) {
+ assert.containsNone(nav, '.nav-link.active');
+ assert.containsN(nav, '.nav-item.o_invisible_modifier', 2);
+ }
+
+ // Does not change when coming back to initial state
+ await testUtils.form.clickSave(form);
+
+ for (const nav of form.el.querySelectorAll(':scope .o_notebook .nav')) {
+ assert.containsNone(nav, '.nav-link.active');
+ assert.containsN(nav, '.nav-item.o_invisible_modifier', 2);
+ }
+
+ form.destroy();
+ });
+
+ QUnit.test('notebook: pages with invisible modifiers', async function (assert) {
+ assert.expect(10);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `<form string="Partners">
+ <sheet>
+ <field name="bar"/>
+ <notebook>
+ <page string="First" attrs='{"invisible": [["bar", "=", false]]}'>
+ <field name="foo"/>
+ </page>
+ <page string="Second" attrs='{"invisible": [["bar", "=", true]]}'>
+ <field name="int_field"/>
+ </page>
+ <page string="Third">
+ <field name="qux"/>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ assert.containsOnce(form, ".o_notebook .nav .nav-link.active",
+ "There should be only one active tab"
+ );
+ assert.isVisible(form.$(".o_notebook .nav .nav-item:first"));
+ assert.hasClass(form.$(".o_notebook .nav .nav-link:first"), "active");
+
+ assert.isNotVisible(form.$(".o_notebook .nav .nav-item:eq(1)"));
+ assert.doesNotHaveClass(form.$(".o_notebook .nav .nav-link:eq(1)"), "active");
+
+ await testUtils.dom.click(form.$(".o_field_widget[name=bar] input"));
+
+ assert.containsOnce(form, ".o_notebook .nav .nav-link.active",
+ "There should be only one active tab"
+ );
+ assert.isNotVisible(form.$(".o_notebook .nav .nav-item:first"));
+ assert.doesNotHaveClass(form.$(".o_notebook .nav .nav-link:first"), "active");
+
+ assert.isVisible(form.$(".o_notebook .nav .nav-item:eq(1)"));
+ assert.hasClass(form.$(".o_notebook .nav .nav-link:eq(1)"), "active");
+
+ form.destroy();
+ });
+
+ QUnit.test('invisible attrs on first notebook page', async function (assert) {
+ assert.expect(6);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id"/>' +
+ '<notebook>' +
+ '<page string="Foo" attrs=\'{"invisible": [["product_id", "!=", false]]}\'>' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '<page string="Bar">' +
+ '<field name="bar"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.hasClass(form.$('.o_notebook .nav .nav-link:first()'), 'active');
+ assert.doesNotHaveClass(form.$('.o_notebook .nav .nav-item:first()'), 'o_invisible_modifier');
+
+ // set a value on the m2o
+ await testUtils.fields.many2one.searchAndClickItem('product_id');
+ assert.doesNotHaveClass(form.$('.o_notebook .nav .nav-link:first()'), 'active');
+ assert.hasClass(form.$('.o_notebook .nav .nav-item:first()'), 'o_invisible_modifier');
+ assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active');
+ assert.hasClass(form.$('.o_notebook .tab-content .tab-pane:nth(1)'), 'active');
+ form.destroy();
+ });
+
+ QUnit.test('invisible attrs on notebook page which has only one page', async function (assert) {
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="bar"/>' +
+ '<notebook>' +
+ '<page string="Foo" attrs=\'{"invisible": [["bar", "!=", false]]}\'>' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.notOk(form.$('.o_notebook .nav .nav-link:first()').hasClass('active'),
+ 'first tab should not be active');
+ assert.ok(form.$('.o_notebook .nav .nav-item:first()').hasClass('o_invisible_modifier'),
+ 'first tab should be invisible');
+
+ // enable checkbox
+ await testUtils.dom.click(form.$('.o_field_boolean input'));
+ assert.ok(form.$('.o_notebook .nav .nav-link:first()').hasClass('active'),
+ 'first tab should be active');
+ assert.notOk(form.$('.o_notebook .nav .nav-item:first()').hasClass('o_invisible_modifier'),
+ 'first tab should be visible');
+
+ form.destroy();
+ });
+
+ QUnit.test('first notebook page invisible', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id"/>' +
+ '<notebook>' +
+ '<page string="Foo" invisible="1">' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '<page string="Bar">' +
+ '<field name="bar"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.notOk(form.$('.o_notebook .nav .nav-item:first()').is(':visible'),
+ 'first tab should be invisible');
+ assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active');
+
+ form.destroy();
+ });
+
+ QUnit.test('autofocus on second notebook page', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id"/>' +
+ '<notebook>' +
+ '<page string="Choucroute">' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '<page string="Cassoulet" autofocus="autofocus">' +
+ '<field name="bar"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.doesNotHaveClass(form.$('.o_notebook .nav .nav-link:first()'), 'active');
+ assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active');
+
+ form.destroy();
+ });
+
+ QUnit.test('invisible attrs on group are re-evaluated on field change', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="bar"/>' +
+ '<group attrs=\'{"invisible": [["bar", "!=", true]]}\'>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit'
+ },
+ });
+
+ assert.containsOnce(form, 'div.o_group:visible');
+ await testUtils.dom.click('.o_field_boolean input', form);
+ assert.containsOnce(form, 'div.o_group:hidden');
+ form.destroy();
+ });
+
+ QUnit.test('invisible attrs with zero value in domain and unset value in data', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.int_field.type = 'monetary';
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '<group attrs=\'{"invisible": [["int_field", "=", 0.0]]}\'>' +
+ '<div class="hello">this should be invisible</div>' +
+ '<field name="int_field"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ assert.isNotVisible(form.$('div.hello'));
+ form.destroy();
+ });
+
+ QUnit.test('reset local state when switching to another view', async function (assert) {
+ assert.expect(3);
+
+ const actionManager = await createActionManager({
+ data: this.data,
+ archs: {
+ 'partner,false,form': `<form>
+ <sheet>
+ <field name="product_id"/>
+ <notebook>
+ <page string="Foo">
+ <field name="foo"/>
+ </page>
+ <page string="Bar">
+ <field name="bar"/>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ 'partner,false,list': '<tree><field name="foo"/></tree>',
+ 'partner,false,search': '<search></search>',
+ },
+ actions: [{
+ id: 1,
+ name: 'Partner',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list'], [false, 'form']],
+ }],
+ });
+
+ await actionManager.doAction(1);
+
+ await testUtils.dom.click(actionManager.$('.o_list_button_add'));
+ assert.containsOnce(actionManager, '.o_form_view');
+
+ // click on second page tab
+ await testUtils.dom.click(actionManager.$('.o_notebook .nav-link:eq(1)'));
+
+ await testUtils.dom.click('.o_control_panel .o_form_button_cancel');
+ assert.containsNone(actionManager, '.o_form_view');
+
+ await testUtils.dom.click(actionManager.$('.o_list_button_add'));
+ // check notebook active page is 0th page
+ assert.hasClass(actionManager.$('.o_notebook .nav-link:eq(0)'), 'active');
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('rendering stat buttons', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<sheet>' +
+ '<div name="button_box">' +
+ '<button class="oe_stat_button">' +
+ '<field name="int_field"/>' +
+ '</button>' +
+ '<button class="oe_stat_button" attrs=\'{"invisible": [["bar", "=", true]]}\'>' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '</div>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.containsN(form, 'button.oe_stat_button', 2);
+ assert.containsOnce(form, 'button.oe_stat_button.o_invisible_modifier');
+
+ var count = 0;
+ await testUtils.mock.intercept(form, "execute_action", function () {
+ count++;
+ });
+ await testUtils.dom.click('.oe_stat_button');
+ assert.strictEqual(count, 1, "should have triggered a execute action");
+ form.destroy();
+ });
+
+ QUnit.test('label uses the string attribute', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<label for="bar" string="customstring"/>' +
+ '<div><field name="bar"/></div>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.containsOnce(form, 'label.o_form_label:contains(customstring)');
+ form.destroy();
+ });
+
+ QUnit.test('input ids for multiple occurrences of fields in form view', async function (assert) {
+ // A same field can occur several times in the view, but its id must be
+ // unique by occurrence, otherwise there is a warning in the console (in
+ // edit mode) as we get several inputs with the same "id" attribute, and
+ // several labels the same "for" attribute.
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="foo"/>
+ <label for="qux"/>
+ <div><field name="qux"/></div>
+ </group>
+ <group>
+ <field name="foo"/>
+ <label for="qux2"/>
+ <div><field name="qux" id="qux2"/></div>
+ </group>
+ </form>`,
+ });
+
+ const fieldIdAttrs = [...form.$('.o_field_widget')].map(n => n.getAttribute('id'));
+ const labelForAttrs = [...form.$('.o_form_label')].map(n => n.getAttribute('for'));
+
+ assert.strictEqual([...new Set(fieldIdAttrs)].length, 4,
+ "should have generated a unique id for each field occurrence");
+ assert.deepEqual(fieldIdAttrs, labelForAttrs,
+ "the for attribute of labels must coincide with field ids");
+
+ form.destroy();
+ });
+
+ QUnit.test('input ids for multiple occurrences of fields in sub form view (inline)', async function (assert) {
+ // A same field can occur several times in the view, but its id must be
+ // unique by occurrence, otherwise there is a warning in the console (in
+ // edit mode) as we get several inputs with the same "id" attribute, and
+ // several labels the same "for" attribute.
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="p">
+ <tree><field name="foo"/></tree>
+ <form>
+ <group>
+ <field name="foo"/>
+ <label for="qux"/>
+ <div><field name="qux"/></div>
+ </group>
+ <group>
+ <field name="foo"/>
+ <label for="qux2"/>
+ <div><field name="qux" id="qux2"/></div>
+ </group>
+ </form>
+ </field>
+ </form>`,
+ });
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+
+ assert.containsOnce(document.body, '.modal .o_form_view');
+
+ const fieldIdAttrs = [...$('.modal .o_form_view .o_field_widget')].map(n => n.getAttribute('id'));
+ const labelForAttrs = [...$('.modal .o_form_view .o_form_label')].map(n => n.getAttribute('for'));
+
+ assert.strictEqual([...new Set(fieldIdAttrs)].length, 4,
+ "should have generated a unique id for each field occurrence");
+ assert.deepEqual(fieldIdAttrs, labelForAttrs,
+ "the for attribute of labels must coincide with field ids");
+
+ form.destroy();
+ });
+
+ QUnit.test('input ids for multiple occurrences of fields in sub form view (not inline)', async function (assert) {
+ // A same field can occur several times in the view, but its id must be
+ // unique by occurrence, otherwise there is a warning in the console (in
+ // edit mode) as we get several inputs with the same "id" attribute, and
+ // several labels the same "for" attribute.
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="p"/></form>',
+ archs: {
+ 'partner,false,list': '<tree><field name="foo"/></tree>',
+ 'partner,false,form': `
+ <form>
+ <group>
+ <field name="foo"/>
+ <label for="qux"/>
+ <div><field name="qux"/></div>
+ </group>
+ <group>
+ <field name="foo"/>
+ <label for="qux2"/>
+ <div><field name="qux" id="qux2"/></div>
+ </group>
+ </form>`
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+
+ assert.containsOnce(document.body, '.modal .o_form_view');
+
+ const fieldIdAttrs = [...$('.modal .o_form_view .o_field_widget')].map(n => n.getAttribute('id'));
+ const labelForAttrs = [...$('.modal .o_form_view .o_form_label')].map(n => n.getAttribute('for'));
+
+ assert.strictEqual([...new Set(fieldIdAttrs)].length, 4,
+ "should have generated a unique id for each field occurrence");
+ assert.deepEqual(fieldIdAttrs, labelForAttrs,
+ "the for attribute of labels must coincide with field ids");
+
+ form.destroy();
+ });
+
+ QUnit.test('two occurrences of invalid field in form view', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.trululu.required = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="trululu"/>
+ <field name="trululu"/>
+ </group>
+ </form>`,
+ });
+
+ await testUtils.form.clickSave(form);
+
+ assert.containsN(form, '.o_form_label.o_field_invalid', 2);
+ assert.containsN(form, '.o_field_many2one.o_field_invalid', 2);
+
+ form.destroy();
+ });
+
+ QUnit.test('tooltips on multiple occurrences of fields and labels', async function (assert) {
+ assert.expect(4);
+
+ const initialDebugMode = odoo.debug;
+ odoo.debug = false;
+
+ this.data.partner.fields.foo.help = 'foo tooltip';
+ this.data.partner.fields.bar.help = 'bar tooltip';
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="foo"/>
+ <label for="bar"/>
+ <div><field name="bar"/></div>
+ </group>
+ <group>
+ <field name="foo"/>
+ <label for="bar2"/>
+ <div><field name="bar" id="bar2"/></div>
+ </group>
+ </form>`,
+ });
+
+ const $fooLabel1 = form.$('.o_form_label:nth(0)');
+ $fooLabel1.tooltip('show', false);
+ $fooLabel1.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "foo tooltip");
+ $fooLabel1.trigger($.Event('mouseleave'));
+
+ const $fooLabel2 = form.$('.o_form_label:nth(2)');
+ $fooLabel2.tooltip('show', false);
+ $fooLabel2.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "foo tooltip");
+ $fooLabel2.trigger($.Event('mouseleave'));
+
+ const $barLabel1 = form.$('.o_form_label:nth(1)');
+ $barLabel1.tooltip('show', false);
+ $barLabel1.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "bar tooltip");
+ $barLabel1.trigger($.Event('mouseleave'));
+
+ const $barLabel2 = form.$('.o_form_label:nth(3)');
+ $barLabel2.tooltip('show', false);
+ $barLabel2.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "bar tooltip");
+ $barLabel2.trigger($.Event('mouseleave'));
+
+ odoo.debug = initialDebugMode;
+ form.destroy();
+ });
+
+ QUnit.test('readonly attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+ await testUtils.form.clickEdit(form);
+
+ assert.containsOnce(form, 'span[name="foo"]',
+ "the foo field widget should be readonly");
+ await testUtils.dom.click(form.$('.o_field_boolean input'));
+ assert.containsOnce(form, 'input[name="foo"]',
+ "the foo field widget should have been rerendered to now be editable");
+ await testUtils.dom.click(form.$('.o_field_boolean input'));
+ assert.containsOnce(form, 'span[name="foo"]',
+ "the foo field widget should have been rerendered to now be readonly again");
+ await testUtils.dom.click(form.$('.o_field_boolean input'));
+ assert.containsOnce(form, 'input[name="foo"]',
+ "the foo field widget should have been rerendered to now be editable again");
+
+ form.destroy();
+ });
+
+ QUnit.test('empty fields have o_form_empty class in readonly mode', async function (assert) {
+ assert.expect(8);
+
+ this.data.partner.fields.foo.default = false; // no default value for this test
+ this.data.partner.records[1].foo = false; // 1 is record with id=2
+ this.data.partner.records[1].trululu = false; // 1 is record with id=2
+ this.data.partner.fields.int_field.readonly = true;
+ this.data.partner.onchanges.foo = function (obj) {
+ if (obj.foo === "hello") {
+ obj.int_field = false;
+ }
+ };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="trululu" attrs="{\'readonly\': [[\'foo\', \'=\', False]]}"/>' +
+ '<field name="int_field"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.containsN(form, '.o_field_widget.o_field_empty', 2,
+ "should have 2 empty fields with correct class");
+ assert.containsN(form, '.o_form_label_empty', 2,
+ "should have 2 muted labels (for the empty fieds) in readonly");
+
+ await testUtils.form.clickEdit(form);
+
+ assert.containsOnce(form, '.o_field_empty',
+ "in edit mode, only empty readonly fields should have the o_field_empty class");
+ assert.containsOnce(form, '.o_form_label_empty',
+ "in edit mode, only labels associated to empty readonly fields should have the o_form_label_empty class");
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'test');
+
+ assert.containsNone(form, '.o_field_empty',
+ "after readonly modifier change, the o_field_empty class should have been removed");
+ assert.containsNone(form, '.o_form_label_empty',
+ "after readonly modifier change, the o_form_label_empty class should have been removed");
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'hello');
+
+ assert.containsOnce(form, '.o_field_empty',
+ "after value changed to false for a readonly field, the o_field_empty class should have been added");
+ assert.containsOnce(form, '.o_form_label_empty',
+ "after value changed to false for a readonly field, the o_form_label_empty class should have been added");
+
+ form.destroy();
+ });
+
+ QUnit.test('empty fields\' labels still get the empty class after widget rerender', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.fields.foo.default = false; // no default value for this test
+ this.data.partner.records[1].foo = false; // 1 is record with id=2
+ this.data.partner.records[1].display_name = false; // 1 is record with id=2
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="display_name" attrs="{\'readonly\': [[\'foo\', \'=\', \'readonly\']]}"/>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.containsN(form, '.o_field_widget.o_field_empty', 2);
+ assert.containsN(form, '.o_form_label_empty', 2,
+ "should have 1 muted label (for the empty fied) in readonly");
+
+ await testUtils.form.clickEdit(form);
+
+ assert.containsNone(form, '.o_field_empty',
+ "in edit mode, only empty readonly fields should have the o_field_empty class");
+ assert.containsNone(form, '.o_form_label_empty',
+ "in edit mode, only labels associated to empty readonly fields should have the o_form_label_empty class");
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'readonly');
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'edit');
+ await testUtils.fields.editInput(form.$('input[name=display_name]'), 'some name');
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'readonly');
+
+ assert.containsNone(form, '.o_field_empty',
+ "there still should not be any empty class on fields as the readonly one is now set");
+ assert.containsNone(form, '.o_form_label_empty',
+ "there still should not be any empty class on labels as the associated readonly field is now set");
+
+ form.destroy();
+ });
+
+ QUnit.test('empty inner readonly fields don\'t have o_form_empty class in "create" mode', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.product_id.readonly = true;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<group>' +
+ '<field name="product_id"/>' +
+ '</group>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ });
+ assert.containsNone(form, '.o_form_label_empty',
+ "no empty class on label");
+ assert.containsNone(form, '.o_field_empty',
+ "no empty class on field");
+ form.destroy();
+ });
+
+ QUnit.test('form view can switch to edit mode', async function (assert) {
+ assert.expect(9);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode');
+ assert.hasClass(form.$('.o_form_view'), 'o_form_readonly');
+ assert.isVisible(form.$buttons.find('.o_form_buttons_view'));
+ assert.isNotVisible(form.$buttons.find('.o_form_buttons_edit'));
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode');
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable');
+ assert.doesNotHaveClass(form.$('.o_form_view'), 'o_form_readonly');
+ assert.isNotVisible(form.$buttons.find('.o_form_buttons_view'));
+ assert.isVisible(form.$buttons.find('.o_form_buttons_edit'));
+ form.destroy();
+ });
+
+ QUnit.test('required attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+ await testUtils.form.clickEdit(form);
+
+ assert.containsOnce(form, 'input[name="foo"].o_required_modifier',
+ "the foo field widget should be required");
+ await testUtils.dom.click('.o_field_boolean input');
+ assert.containsOnce(form, 'input[name="foo"]:not(.o_required_modifier)',
+ "the foo field widget should now have been marked as non-required");
+ await testUtils.dom.click('.o_field_boolean input');
+ assert.containsOnce(form, 'input[name="foo"].o_required_modifier',
+ "the foo field widget should now have been marked as required again");
+
+ form.destroy();
+ });
+
+ QUnit.test('required fields should have o_required_modifier in readonly mode', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.foo.required = true;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, 'span.o_required_modifier', form);
+
+ await testUtils.form.clickEdit(form);
+ assert.containsOnce(form, 'input.o_required_modifier',
+ "in edit mode, should have 1 input with o_required_modifier");
+ form.destroy();
+ });
+
+ QUnit.test('required float fields works as expected', async function (assert) {
+ assert.expect(10);
+
+ this.data.partner.fields.qux.required = true;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="qux"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.hasClass(form.$('input[name="qux"]'), 'o_required_modifier');
+ assert.strictEqual(form.$('input[name="qux"]').val(), "0.0",
+ "qux input is 0 by default (float field)");
+
+ await testUtils.form.clickSave(form);
+
+ assert.containsNone(form.$('input[name="qux"]'), "should have switched to readonly");
+
+ await testUtils.form.clickEdit(form);
+
+ await testUtils.fields.editInput(form.$('input[name=qux]'), '1');
+
+ await testUtils.form.clickSave(form);
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('input[name="qux"]').val(), "1.0",
+ "qux input is properly formatted");
+
+ assert.verifySteps(['onchange', 'create', 'read', 'write', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('separators', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<separator string="Geolocation"/>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, 'div.o_horizontal_separator');
+ form.destroy();
+ });
+
+ QUnit.test('invisible attrs on separators', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<separator string="Geolocation" attrs=\'{"invisible": [["bar", "=", True]]}\'/>'+
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.hasClass(form.$('div.o_horizontal_separator'), 'o_invisible_modifier');
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons in form view', async function (assert) {
+ assert.expect(8);
+
+ var rpcCount = 0;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="state" invisible="1"/>' +
+ '<header>' +
+ '<button name="post" class="p" string="Confirm" type="object"/>' +
+ '<button name="some_method" class="s" string="Do it" type="object"/>' +
+ '<button name="some_other_method" states="ab,ef" string="Do not" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<button string="Geolocate" name="geo_localize" icon="fa-check" type="object"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.containsOnce(form, 'button.btn i.fa.fa-check');
+ assert.containsN(form, '.o_form_statusbar button', 3);
+ assert.containsOnce(form, 'button.p[name="post"]:contains(Confirm)');
+ assert.containsN(form, '.o_form_statusbar button:visible', 2);
+
+ await testUtils.mock.intercept(form, 'execute_action', function (ev) {
+ assert.strictEqual(ev.data.action_data.name, 'post',
+ "should trigger execute_action with correct method name");
+ assert.deepEqual(ev.data.env.currentID, 2, "should have correct id in ev data");
+ ev.data.on_success();
+ ev.data.on_closed();
+ });
+ rpcCount = 0;
+ await testUtils.dom.click('.o_form_statusbar button.p', form);
+
+ assert.strictEqual(rpcCount, 1, "should have done 1 rpcs to reload");
+
+ await testUtils.mock.intercept(form, 'execute_action', function (ev) {
+ ev.data.on_fail();
+ });
+ await testUtils.dom.click('.o_form_statusbar button.s', form);
+
+ assert.strictEqual(rpcCount, 1,
+ "should have done 1 rpc, because we do not reload anymore if the server action fails");
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons classes in form view', async function (assert) {
+ assert.expect(16);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<header>' +
+ '<button name="0"/>' +
+ '<button name="1" class="btn-primary"/>' +
+ '<button name="2" class="oe_highlight"/>' +
+ '<button name="3" class="btn-secondary"/>' +
+ '<button name="4" class="btn-link"/>' +
+ '<button name="5" class="oe_link"/>' +
+ '<button name="6" class="btn-success"/>' +
+ '<button name="7" class="o_this_is_a_button"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<button name="8"/>' +
+ '<button name="9" class="btn-primary"/>' +
+ '<button name="10" class="oe_highlight"/>' +
+ '<button name="11" class="btn-secondary"/>' +
+ '<button name="12" class="btn-link"/>' +
+ '<button name="13" class="oe_link"/>' +
+ '<button name="14" class="btn-success"/>' +
+ '<button name="15" class="o_this_is_a_button"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.hasAttrValue(form.$('button[name="0"]'), 'class', 'btn btn-secondary');
+ assert.hasAttrValue(form.$('button[name="1"]'), 'class', 'btn btn-primary');
+ assert.hasAttrValue(form.$('button[name="2"]'), 'class', 'btn btn-primary');
+ assert.hasAttrValue(form.$('button[name="3"]'), 'class', 'btn btn-secondary');
+ assert.hasAttrValue(form.$('button[name="4"]'), 'class', 'btn btn-link');
+ assert.hasAttrValue(form.$('button[name="5"]'), 'class', 'btn btn-link');
+ assert.hasAttrValue(form.$('button[name="6"]'), 'class', 'btn btn-success');
+ assert.hasAttrValue(form.$('button[name="7"]'), 'class', 'btn o_this_is_a_button btn-secondary');
+ assert.hasAttrValue(form.$('button[name="8"]'), 'class', 'btn btn-secondary');
+ assert.hasAttrValue(form.$('button[name="9"]'), 'class', 'btn btn-primary');
+ assert.hasAttrValue(form.$('button[name="10"]'), 'class', 'btn btn-primary');
+ assert.hasAttrValue(form.$('button[name="11"]'), 'class', 'btn btn-secondary');
+ assert.hasAttrValue(form.$('button[name="12"]'), 'class', 'btn btn-link');
+ assert.hasAttrValue(form.$('button[name="13"]'), 'class', 'btn btn-link');
+ assert.hasAttrValue(form.$('button[name="14"]'), 'class', 'btn btn-success');
+ assert.hasAttrValue(form.$('button[name="15"]'), 'class', 'btn o_this_is_a_button');
+
+ form.destroy();
+ });
+
+ QUnit.test('button in form view and long willStart', async function (assert) {
+ assert.expect(6);
+
+ var rpcCount = 0;
+
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ assert.step('load '+rpcCount);
+ if (rpcCount === 2) {
+ return $.Deferred();
+ }
+ return $.Deferred().resolve();
+ },
+ }));
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="state" invisible="1"/>' +
+ '<header>' +
+ '<button name="post" class="p" string="Confirm" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" widget="asyncwidget"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.verifySteps(['load 1']);
+
+ await testUtils.mock.intercept(form, 'execute_action', function (ev) {
+ ev.data.on_success();
+ ev.data.on_closed();
+ });
+
+ await testUtils.dom.click('.o_form_statusbar button.p', form);
+ assert.verifySteps(['load 2']);
+
+ testUtils.mock.intercept(form, 'execute_action', function (ev) {
+ ev.data.on_success();
+ ev.data.on_closed();
+ });
+
+ await testUtils.dom.click('.o_form_statusbar button.p', form);
+ assert.verifySteps(['load 3']);
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons in form view, new record', async function (assert) {
+ // this simulates a situation similar to the settings forms.
+ assert.expect(7);
+
+ var resID;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="post" class="p" string="Confirm" type="object"/>' +
+ '<button name="some_method" class="s" string="Do it" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<button string="Geolocate" name="geo_localize" icon="fa-check" type="object"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'create') {
+ return this._super.apply(this, arguments).then(function (result) {
+ resID = result;
+ return resID;
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.mock.intercept(form, 'execute_action', function (event) {
+ assert.step('execute_action');
+ assert.deepEqual(event.data.env.currentID, resID,
+ "execute action should be done on correct record id");
+ event.data.on_success();
+ event.data.on_closed();
+ });
+ await testUtils.dom.click('.o_form_statusbar button.p', form);
+
+ assert.verifySteps(['onchange', 'create', 'read', 'execute_action', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('buttons in form view, new record, with field id in view', async function (assert) {
+ assert.expect(7);
+ // buttons in form view are one of the rare example of situation when we
+ // save a record without reloading it immediately, because we only care
+ // about its id for the next step. But at some point, if the field id
+ // is in the view, it was registered in the changes, and caused invalid
+ // values in the record (data.id was set to null)
+
+ var resID;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="post" class="p" string="Confirm" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="id" invisible="1"/>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'create') {
+ return this._super.apply(this, arguments).then(function (result) {
+ resID = result;
+ return resID;
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.mock.intercept(form, 'execute_action', function (event) {
+ assert.step('execute_action');
+ assert.deepEqual(event.data.env.currentID, resID,
+ "execute action should be done on correct record id");
+ event.data.on_success();
+ event.data.on_closed();
+ });
+ await testUtils.dom.click('.o_form_statusbar button.p', form);
+
+ assert.verifySteps(['onchange', 'create', 'read', 'execute_action', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('change and save char', async function (assert) {
+ assert.expect(6);
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.ok(true, "should call the /write route");
+ }
+ return this._super(route, args);
+ },
+ res_id: 2,
+ });
+
+ assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode');
+ assert.containsOnce(form, 'span:contains(blip)',
+ "should contain span with field value");
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode');
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+ await testUtils.form.clickSave(form);
+
+ assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode');
+ assert.containsOnce(form, 'span:contains(tralala)',
+ "should contain span with field value");
+ form.destroy();
+ });
+
+ QUnit.test('properly reload data from server', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ args.args[1].foo = "apple";
+ }
+ return this._super(route, args);
+ },
+ res_id: 2,
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+ await testUtils.form.clickSave(form);
+ assert.containsOnce(form, 'span:contains(apple)',
+ "should contain span with field value");
+ form.destroy();
+ });
+
+ QUnit.test('disable buttons until reload data from server', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ args.args[1].foo = "apple";
+ } else if (args.method === 'read') {
+ // Block the 'read' call
+ var result = this._super.apply(this, arguments);
+ return Promise.resolve(def).then(result);
+ }
+ return this._super(route, args);
+ },
+ res_id: 2,
+ });
+
+ var def = testUtils.makeTestPromise();
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+ await testUtils.form.clickSave(form);
+
+ // Save button should be disabled
+ assert.hasAttrValue(form.$buttons.find('.o_form_button_save'), 'disabled', 'disabled');
+ // Release the 'read' call
+ await def.resolve();
+ await testUtils.nextTick();
+
+ // Edit button should be enabled after the reload
+ assert.hasAttrValue(form.$buttons.find('.o_form_button_edit'), 'disabled', undefined);
+
+ form.destroy();
+ });
+
+ QUnit.test('properly apply onchange in simple case', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length + 1000;
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/><field name="int_field"/></group>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), "9",
+ "should contain input with initial value");
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), "1007",
+ "should contain input with onchange applied");
+ form.destroy();
+ });
+
+ QUnit.test('properly apply onchange when changed field is active field', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = {
+ int_field: function (obj) {
+ obj.int_field = 14;
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="int_field"/></group>' +
+ '</form>',
+ res_id: 2,
+ viewOptions: {mode: 'edit'},
+ });
+
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), "9",
+ "should contain input with initial value");
+
+ await testUtils.fields.editInput(form.$('input[name=int_field]'), '666');
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), "14",
+ "value should have been set to 14 by onchange");
+
+ await testUtils.form.clickSave(form);
+
+ assert.strictEqual(form.$('.o_field_widget[name=int_field]').text(), "14",
+ "value should still be 14");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange send only the present fields to the server', async function (assert) {
+ assert.expect(1);
+ this.data.partner.records[0].product_id = false;
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.foo = obj.foo + " alligator";
+ };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="bar"/>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '<field name="timmy"/>' +
+ '</form>',
+ archs: {
+ "partner_type,false,list": '<tree><field name="name"/></tree>'
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === "onchange") {
+ assert.deepEqual(args.args[3],
+ {"foo": "1", "p": "", "p.bar": "", "p.product_id": "", "timmy": "", "timmy.name": ""},
+ "should send only the fields used in the views");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange only send present fields value', async function (assert) {
+ assert.expect(1);
+ this.data.partner.onchanges.foo = function (obj) {};
+
+ let checkOnchange = false;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<field name="qux"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === "onchange" && checkOnchange) {
+ assert.deepEqual(args.args[1], {
+ display_name: "first record",
+ foo: "tralala",
+ id: 1,
+ p: [[0, args.args[1].p[0][1], {"display_name": "valid line", "qux": 12.4}]]
+ }, "should send the values for the present fields");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ // add a o2m row
+ await testUtils.dom.click('.o_field_x2many_list_row_add a');
+ form.$('.o_field_one2many input:first').focus();
+ await testUtils.nextTick();
+ await testUtils.fields.editInput(form.$('.o_field_one2many input[name=display_name]'), 'valid line');
+ form.$('.o_field_one2many input:last').focus();
+ await testUtils.nextTick();
+ await testUtils.fields.editInput(form.$('.o_field_one2many input[name=qux]'), '12.4');
+
+ // trigger an onchange by modifying foo
+ checkOnchange = true;
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ form.destroy();
+ });
+
+ QUnit.test('evaluate in python field options', async function (assert) {
+ assert.expect(1);
+
+ var isOk = false;
+ var tmp = py.eval;
+ py.eval = function (expr) {
+ if (expr === "{'horizontal': true}") {
+ isOk = true;
+ }
+ return tmp.apply(tmp, arguments);
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" options="{\'horizontal\': true}"/>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ py.eval = tmp;
+
+ assert.ok(isOk, "should have evaluated the field options");
+ form.destroy();
+ });
+
+ QUnit.test('can create a record with default values', async function (assert) {
+ assert.expect(5);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ context: {active_field: 2},
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.strictEqual(args.kwargs.context.active_field, 2,
+ "should have send the correct context");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ var n = this.data.partner.records.length;
+
+ await testUtils.form.clickCreate(form);
+ assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode');
+
+ assert.strictEqual(form.$('input:first').val(), "My little Foo Value",
+ "should have correct default_get value");
+ await testUtils.form.clickSave(form);
+ assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode');
+ assert.strictEqual(this.data.partner.records.length, n + 1, "should have created a record");
+ form.destroy();
+ });
+
+ QUnit.test('default record with a one2many and an onchange on sub field', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges.foo = function () {};
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'onchange') {
+ assert.deepEqual(args.args[3], {
+ p: '',
+ 'p.foo': '1'
+ }, "onchangeSpec should be correct (with sub fields)");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.verifySteps(['onchange']);
+ form.destroy();
+ });
+
+ QUnit.test('reference field in one2many list', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].reference = 'partner,2';
+
+ var form = await createView({
+ View: FormView,
+ model: 'user',
+ data: this.data,
+ arch: `<form>
+ <field name="name"/>
+ <field name="partner_ids">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="reference"/>
+ </tree>
+ </field>
+ </form>`,
+ archs: {
+ 'partner,false,form': '<form><field name="display_name"/></form>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+ res_id: 17,
+ });
+ // current form
+ await testUtils.form.clickEdit(form);
+
+ // open the modal form view of the record pointed by the reference field
+ await testUtils.dom.click(form.$('table td[title="first record"]'));
+ await testUtils.dom.click(form.$('table td button.o_external_button'));
+
+ // edit the record in the modal
+ await testUtils.fields.editInput($('.modal-body input[name="display_name"]'), 'New name');
+ await testUtils.dom.click($('.modal-dialog footer button:first-child'));
+
+ assert.containsOnce(form, '.o_field_cell[title="New name"]', 'should not crash and value must be edited');
+
+ form.destroy();
+ });
+
+ QUnit.test('toolbar is hidden when switching to edit mode', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '</form>',
+ viewOptions: {hasActionMenus: true},
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, '.o_cp_action_menus');
+
+ await testUtils.form.clickEdit(form);
+
+ assert.containsNone(form, '.o_cp_action_menus');
+
+ await testUtils.form.clickDiscard(form);
+
+ assert.containsOnce(form, '.o_cp_action_menus');
+
+ form.destroy();
+ });
+
+ QUnit.test('basic default record', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.foo.default = "default foo value";
+
+ var count = 0;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ count++;
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(form.$('input[name=foo]').val(), "default foo value", "should have correct default");
+ assert.strictEqual(count, 1, "should do only one rpc");
+ form.destroy();
+ });
+
+ QUnit.test('make default record with non empty one2many', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.fields.p.default = [
+ [6, 0, []], // replace with zero ids
+ [0, 0, {foo: "new foo1", product_id: 41, p: [] }], // create a new value
+ [0, 0, {foo: "new foo2", product_id: 37, p: [] }], // create a new value
+ ];
+
+ var nameGetCount = 0;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ nameGetCount++;
+ }
+ return this._super(route, args);
+ },
+ });
+ assert.containsOnce(form, 'td:contains(new foo1)',
+ "should have new foo1 value in one2many");
+ assert.containsOnce(form, 'td:contains(new foo2)',
+ "should have new foo2 value in one2many");
+ assert.containsOnce(form, 'td:contains(xphone)',
+ "should have a cell with the name field 'product_id', set to xphone");
+ assert.strictEqual(nameGetCount, 0, "should have done no nameget");
+ form.destroy();
+ });
+
+ QUnit.test('make default record with non empty many2one', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.trululu.default = 4;
+
+ var nameGetCount = 0;
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners"><field name="trululu"/></form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ nameGetCount++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), 'aaa',
+ "default value should be correctly displayed");
+ assert.strictEqual(nameGetCount, 0, "should have done no name_get");
+
+ form.destroy();
+ });
+
+ QUnit.test('form view properly change its title', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record',
+ "should have the display name of the record as title");
+
+ await testUtils.form.clickCreate(form);
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), _t("New"),
+ "should have the display name of the record as title");
+
+ form.destroy();
+ });
+
+ QUnit.test('archive/unarchive a record', async function (assert) {
+ assert.expect(10);
+
+ // add active field on partner model to have archive option
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { hasActionMenus: true },
+ arch: '<form><field name="active"/><field name="foo"/></form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'action_archive') {
+ this.data.partner.records[0].active = false;
+ return Promise.resolve();
+ }
+ if (args.method === 'action_unarchive') {
+ this.data.partner.records[0].active = true;
+ return Promise.resolve();
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await cpHelpers.toggleActionMenu(form);
+ assert.containsOnce(form, '.o_cp_action_menus a:contains(Archive)');
+
+ await cpHelpers.toggleMenuItem(form, "Archive");
+ assert.containsOnce(document.body, '.modal');
+
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+ await cpHelpers.toggleActionMenu(form);
+ assert.containsOnce(form, '.o_cp_action_menus a:contains(Unarchive)');
+
+ await cpHelpers.toggleMenuItem(form, "Unarchive");
+ await cpHelpers.toggleActionMenu(form);
+ assert.containsOnce(form, '.o_cp_action_menus a:contains(Archive)');
+
+ assert.verifySteps([
+ 'read',
+ 'action_archive',
+ 'read',
+ 'action_unarchive',
+ 'read',
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('archive action with active field not in view', async function (assert) {
+ assert.expect(2);
+
+ // add active field on partner model, but do not put it in the view
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { hasActionMenus: true },
+ arch: '<form><field name="foo"/></form>',
+ });
+
+ await cpHelpers.toggleActionMenu(form);
+ assert.containsNone(form, '.o_cp_action_menus a:contains(Archive)');
+ assert.containsNone(form, '.o_cp_action_menus a:contains(Unarchive)');
+
+ form.destroy();
+ });
+
+ QUnit.test('can duplicate a record', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {hasActionMenus: true},
+ });
+
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record',
+ "should have the display name of the record as title");
+
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Duplicate");
+
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record (copy)',
+ "should have duplicated the record");
+
+ assert.strictEqual(form.mode, "edit", 'should be in edit mode');
+ form.destroy();
+ });
+
+ QUnit.test('duplicating a record preserve the context', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {hasActionMenus: true, context: {hey: 'hoy'}},
+ mockRPC: function (route, args) {
+ if (args.method === 'read') {
+ // should have 2 read, one for initial load, second for
+ // read after duplicating
+ assert.strictEqual(args.kwargs.context.hey, 'hoy',
+ "should have send the correct context");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Duplicate");
+
+ form.destroy();
+ });
+
+ QUnit.test('cannot duplicate a record', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners" duplicate="false">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {hasActionMenus: true},
+ });
+
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record',
+ "should have the display name of the record as title");
+ assert.containsNone(form, '.o_cp_action_menus a:contains(Duplicate)',
+ "should not contains a 'Duplicate' action");
+ form.destroy();
+ });
+
+ QUnit.test('clicking on stat buttons in edit mode', async function (assert) {
+ assert.expect(9);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<sheet>' +
+ '<div name="button_box">' +
+ '<button class="oe_stat_button">' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '</div>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].foo, "tralala", "should have saved the changes");
+ }
+ assert.step(args.method);
+ return this._super(route, args);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ var count = 0;
+ await testUtils.mock.intercept(form, "execute_action", function (event) {
+ event.stopPropagation();
+ count++;
+ });
+ await testUtils.dom.click('.oe_stat_button');
+ assert.strictEqual(count, 1, "should have triggered a execute action");
+ assert.strictEqual(form.mode, "edit", "form view should be in edit mode");
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+ await testUtils.dom.click('.oe_stat_button:first');
+
+ assert.strictEqual(form.mode, "edit", "form view should be in edit mode");
+ assert.strictEqual(count, 2, "should have triggered a execute action");
+ assert.verifySteps(['read', 'write', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('clicking on stat buttons save and reload in edit mode', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<sheet>' +
+ '<div name="button_box">' +
+ '<button class="oe_stat_button" type="action">' +
+ '<field name="int_field" widget="statinfo" string="Some number"/>' +
+ '</button>' +
+ '</div>' +
+ '<group>' +
+ '<field name="name"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ // simulate an override of the model...
+ args.args[1].display_name = "GOLDORAK";
+ args.args[1].name = "GOLDORAK";
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'second record',
+ "should have correct display_name");
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=name]'), 'some other name');
+
+ await testUtils.dom.click('.oe_stat_button');
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'GOLDORAK',
+ "should have correct display_name");
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons with attr "special" do not trigger a save', async function (assert) {
+ assert.expect(4);
+
+ var executeActionCount = 0;
+ var writeCount = 0;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<button string="Do something" class="btn-primary" name="abc" type="object"/>' +
+ '<button string="Or discard" class="btn-secondary" special="cancel"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ writeCount++;
+ }
+ return this._super(route, args);
+ },
+ });
+ await testUtils.mock.intercept(form, "execute_action", function () {
+ executeActionCount++;
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ // make the record dirty
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ await testUtils.dom.click(form.$('button:contains(Do something)'));
+ //TODO: VSC: add a next tick ?
+ assert.strictEqual(writeCount, 1, "should have triggered a write");
+ assert.strictEqual(executeActionCount, 1, "should have triggered a execute action");
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'abcdef');
+
+ await testUtils.dom.click(form.$('button:contains(Or discard)'));
+ assert.strictEqual(writeCount, 1, "should not have triggered a write");
+ assert.strictEqual(executeActionCount, 2, "should have triggered a execute action");
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons with attr "special=save" save', async function (assert) {
+ assert.expect(5);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<button string="Save" class="btn-primary" special="save"/>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ execute_action: function () {
+ assert.step('execute_action');
+ },
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+ await testUtils.dom.click(form.$('button[special="save"]'));
+ assert.verifySteps(['read', 'write', 'read', 'execute_action']);
+
+ form.destroy();
+ });
+
+ QUnit.test('missing widgets do not crash', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.foo.type = 'new field type without widget';
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ assert.containsOnce(form, '.o_field_widget');
+ form.destroy();
+ });
+
+ QUnit.test('nolabel', async function (assert) {
+ assert.expect(6);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<group class="firstgroup"><field name="foo" nolabel="1"/></group>' +
+ '<group class="secondgroup">'+
+ '<field name="product_id"/>' +
+ '<field name="int_field" nolabel="1"/><field name="qux" nolabel="1"/>' +
+ '</group>' +
+ '<group><field name="bar"/></group>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsN(form, "label.o_form_label", 2);
+ assert.strictEqual(form.$("label.o_form_label").first().text(), "Product",
+ "one should be the one for the product field");
+ assert.strictEqual(form.$("label.o_form_label").eq(1).text(), "Bar",
+ "one should be the one for the bar field");
+
+ assert.hasAttrValue(form.$('.firstgroup td').first(), 'colspan', undefined,
+ "foo td should have a default colspan (1)");
+ assert.containsN(form, '.secondgroup tr', 2,
+ "int_field and qux should have same tr");
+
+ assert.containsN(form, '.secondgroup tr:first td', 2,
+ "product_id field should be on its own tr");
+ form.destroy();
+ });
+
+ QUnit.test('many2one in a one2many', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[1].product_id = 37;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+ assert.containsOnce(form, 'td:contains(xphone)', "should display the name of the many2one");
+ form.destroy();
+ });
+
+ QUnit.test('circular many2many\'s', async function (assert) {
+ assert.expect(4);
+ this.data.partner_type.fields.partner_ids = {string: "partners", type: "many2many", relation: 'partner'};
+ this.data.partner.records[0].timmy = [12];
+ this.data.partner_type.records[0].partner_ids = [1];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="timmy">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="partner_ids">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, 'td:contains(gold)',
+ "should display the name of the many2many on the original form");
+ await testUtils.dom.click(form.$('td:contains(gold)'));
+
+ assert.containsOnce(document.body, '.modal');
+ assert.containsOnce($('.modal'), 'td:contains(first record)',
+ "should display the name of the many2many on the modal form");
+
+ await testUtils.dom.click('.modal td:contains(first record)');
+ assert.containsN(document.body, '.modal', 2,
+ "there should be 2 modals (partner on top of partner_type) opened");
+
+ form.destroy();
+ });
+
+ QUnit.test('discard changes on a non dirty form view', async function (assert) {
+ assert.expect(4);
+
+ var nbWrite = 0;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ res_id: 1,
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ nbWrite++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop',
+ "input should contain yop");
+
+ // click on discard
+ await testUtils.form.clickDiscard(form);
+ assert.containsNone(document.body, '.modal', 'no confirm modal should be displayed');
+ assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop');
+
+ assert.strictEqual(nbWrite, 0, "no write RPC should have been done");
+ form.destroy();
+ });
+
+ QUnit.test('discard changes on a dirty form view', async function (assert) {
+ assert.expect(7);
+
+ var nbWrite = 0;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ res_id: 1,
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ nbWrite++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // switch to edit mode and edit the foo field
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop");
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should contain new value");
+
+ // click on discard and cancel the confirm request
+ await testUtils.form.clickDiscard(form);
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
+ await testUtils.dom.click('.modal-footer .btn-secondary');
+ assert.strictEqual(form.$('input').val(), 'new value', 'input should still contain new value');
+
+ // click on discard and confirm
+ await testUtils.form.clickDiscard(form);
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
+ await testUtils.dom.click('.modal-footer .btn-primary');
+ assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop');
+
+ assert.strictEqual(nbWrite, 0, "no write RPC should have been done");
+ form.destroy();
+ });
+
+ QUnit.test('discard changes on a dirty form view (for date field)', async function (assert) {
+ assert.expect(1);
+
+ // this test checks that the basic model properly handles date object
+ // when they are discarded and saved. This may be an issue because
+ // dates are saved as moment object, and were at one point stringified,
+ // then parsed into string, which is wrong.
+
+ this.data.partner.fields.date.default = "2017-01-25";
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="date"></field></form>',
+ intercepts: {
+ history_back: function () {
+ form.update({}, {reload: false});
+ }
+ },
+ });
+
+ // focus the buttons before clicking on them to precisely reproduce what
+ // really happens (mostly because the datepicker lib need that focus
+ // event to properly focusout the input, otherwise it crashes later on
+ // when the 'blur' event is triggered by the re-rendering)
+ form.$buttons.find('.o_form_button_cancel').focus();
+ await testUtils.dom.click('.o_form_button_cancel');
+ form.$buttons.find('.o_form_button_save').focus();
+ await testUtils.dom.click('.o_form_button_save');
+ assert.containsOnce(form, 'span:contains(2017)');
+
+ form.destroy();
+ });
+
+ QUnit.test('discard changes on relational data on new record', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><sheet><group>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group></sheet></form>',
+ intercepts: {
+ history_back: function () {
+ assert.ok(true, "should have sent correct event");
+ // simulate the response from the action manager, in the case
+ // where we have only one active view (the form). If there
+ // was another view, we would have switched to that view
+ // instead
+ form.update({}, {reload: false});
+ }
+ },
+ });
+
+ // edit the p field
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.many2one.clickOpenDropdown('product_id');
+ await testUtils.fields.many2one.clickHighlightedItem('product_id');
+
+ assert.strictEqual(form.$('.o_field_widget[name=product_id] input').val(), 'xphone',
+ "input should contain xphone");
+
+ // click on discard and confirm
+ await testUtils.form.clickDiscard(form);
+ await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
+
+ assert.notOk(form.$el.prop('outerHTML').match('xphone'),
+ "the string xphone should not be present after discarding");
+ form.destroy();
+ });
+
+ QUnit.test('discard changes on a new (non dirty, except for defaults) form view', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.foo.default = "ABC";
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ intercepts: {
+ history_back: function () {
+ assert.ok(true, "should have sent correct event");
+ }
+ }
+ });
+
+ // edit the foo field
+ assert.strictEqual(form.$('input[name=foo]').val(), 'ABC',
+ "input should contain ABC");
+
+ await testUtils.form.clickDiscard(form);
+
+ assert.containsNone(document.body, '.modal',
+ "there should not be a confirm modal");
+
+ form.destroy();
+ });
+
+ QUnit.test('discard changes on a new (dirty) form view', async function (assert) {
+ assert.expect(8);
+
+ this.data.partner.fields.foo.default = "ABC";
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ intercepts: {
+ history_back: function () {
+ assert.ok(true, "should have sent correct event");
+ // simulate the response from the action manager, in the case
+ // where we have only one active view (the form). If there
+ // was another view, we would have switched to that view
+ // instead
+ form.update({}, {reload: false});
+ }
+ },
+ });
+
+ // edit the foo field
+ assert.strictEqual(form.$('input').val(), 'ABC', 'input should contain ABC');
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'DEF');
+
+ // discard the changes and check it has properly been discarded
+ await testUtils.form.clickDiscard(form);
+ assert.containsOnce(document.body, '.modal', 'there should be a confirm modal');
+ assert.strictEqual(form.$('input').val(), 'DEF', 'input should be DEF');
+ await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
+ assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC');
+
+ // redirty and discard the field foo (to make sure initial changes haven't been lost)
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'GHI');
+ await testUtils.form.clickDiscard(form);
+ assert.strictEqual(form.$('input').val(), 'GHI', 'input should be GHI');
+ await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
+ assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC');
+
+ form.destroy();
+ });
+
+ QUnit.test('discard changes on a duplicated record', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ res_id: 1,
+ viewOptions: {hasActionMenus: true},
+ });
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+ await testUtils.form.clickSave(form);
+
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Duplicate");
+
+ assert.strictEqual(form.$('input[name=foo]').val(), 'tralala', 'input should contain ABC');
+
+ await testUtils.form.clickDiscard(form);
+
+ assert.containsNone(document.body, '.modal', "there should not be a confirm modal");
+
+ form.destroy();
+ });
+
+ QUnit.test("switching to another record from a dirty one", async function (assert) {
+ assert.expect(11);
+
+ var nbWrite = 0;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 1,
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ nbWrite++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should be 1");
+ assert.strictEqual(cpHelpers.getPagerSize(form), '2', "pager limit should be 2");
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop");
+
+ // edit the foo field
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
+ assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value');
+
+ // click on the pager to switch to the next record and cancel the confirm request
+ await cpHelpers.pagerNext(form);
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
+ await testUtils.dom.click('.modal-footer .btn-secondary'); // click on cancel
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should still contain new value");
+ assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should still be 1");
+
+ // click on the pager to switch to the next record and confirm
+ await cpHelpers.pagerNext(form);
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
+ await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
+ assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "input should contain blip");
+ assert.strictEqual(cpHelpers.getPagerValue(form), '2', "pager value should be 2");
+
+ assert.strictEqual(nbWrite, 0, 'no write RPC should have been done');
+ form.destroy();
+ });
+
+ QUnit.test('handling dirty state: switching to another record', async function (assert) {
+ assert.expect(12);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"></field>' +
+ '<field name="priority" widget="priority"></field>' +
+ '</form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 1,
+ });
+
+ assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should be 1");
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop");
+
+ // edit the foo field
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should contain new value");
+
+ await testUtils.form.clickSave(form);
+
+ // click on the pager to switch to the next record and cancel the confirm request
+ await cpHelpers.pagerNext(form);
+ assert.containsNone(document.body, '.modal:visible',
+ "no confirm modal should be displayed");
+ assert.strictEqual(cpHelpers.getPagerValue(form), '2', "pager value should be 2");
+
+ assert.containsN(form, '.o_priority .fa-star-o', 2,
+ 'priority widget should have been rendered with correct value');
+
+ // edit the value in readonly
+ await testUtils.dom.click(form.$('.o_priority .fa-star-o:first')); // click on the first star
+ assert.containsOnce(form, '.o_priority .fa-star',
+ 'priority widget should have been updated');
+
+ await cpHelpers.pagerNext(form);
+ assert.containsNone(document.body, '.modal:visible',
+ "no confirm modal should be displayed");
+ assert.strictEqual(cpHelpers.getPagerValue(form), '1', "pager value should be 1");
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should contain yop");
+
+ // edit the foo field
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'wrong value');
+
+ await testUtils.form.clickDiscard(form);
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
+ await testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
+ await cpHelpers.pagerNext(form);
+ assert.strictEqual(cpHelpers.getPagerValue(form), '2', "pager value should be 2");
+ form.destroy();
+ });
+
+ QUnit.test('restore local state when switching to another record', async function (assert) {
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<notebook>' +
+ '<page string="First Page" name="first">' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '<page string="Second page" name="second">' +
+ '<field name="bar"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 1,
+ });
+
+ // click on second page tab
+ await testUtils.dom.click(form.$('.o_notebook .nav-link:eq(1)'));
+
+ assert.doesNotHaveClass(form.$('.o_notebook .nav-link:eq(0)'), 'active');
+ assert.hasClass(form.$('.o_notebook .nav-link:eq(1)'), 'active');
+
+ // click on the pager to switch to the next record
+ await cpHelpers.pagerNext(form);
+
+ assert.doesNotHaveClass(form.$('.o_notebook .nav-link:eq(0)'), 'active');
+ assert.hasClass(form.$('.o_notebook .nav-link:eq(1)'), 'active');
+ form.destroy();
+ });
+
+ QUnit.test('pager is hidden in create mode', async function (assert) {
+ assert.expect(7);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ });
+
+ assert.containsOnce(form, '.o_pager');
+ assert.strictEqual(cpHelpers.getPagerValue(form), "1",
+ "current pager value should be 1");
+ assert.strictEqual(cpHelpers.getPagerSize(form), "2",
+ "current pager limit should be 1");
+
+ await testUtils.form.clickCreate(form);
+
+ assert.containsNone(form, '.o_pager');
+
+ await testUtils.form.clickSave(form);
+
+ assert.containsOnce(form, '.o_pager');
+ assert.strictEqual(cpHelpers.getPagerValue(form), "3",
+ "current pager value should be 3");
+ assert.strictEqual(cpHelpers.getPagerSize(form), "3",
+ "current pager limit should be 3");
+
+ form.destroy();
+ });
+
+ QUnit.test('switching to another record, in readonly mode', async function (assert) {
+ assert.expect(5);
+
+ var pushStateCount = 0;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 1,
+ intercepts: {
+ push_state: function (event) {
+ pushStateCount++;
+ }
+ }
+ });
+
+ assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode');
+ assert.strictEqual(cpHelpers.getPagerValue(form), "1", 'pager value should be 1');
+
+ await cpHelpers.pagerNext(form);
+
+ assert.strictEqual(cpHelpers.getPagerValue(form), "2", 'pager value should be 2');
+ assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode');
+
+ assert.strictEqual(pushStateCount, 2, "should have triggered 2 push_state");
+ form.destroy();
+ });
+
+ QUnit.test('modifiers are reevaluated when creating new record', async function (assert) {
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet><group>' +
+ '<field name="foo" class="foo_field" attrs=\'{"invisible": [["bar", "=", True]]}\'/>' +
+ '<field name="bar"/>' +
+ '</group></sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, 'span.foo_field');
+ assert.isNotVisible(form.$('span.foo_field'));
+
+ await testUtils.form.clickCreate(form);
+
+ assert.containsOnce(form, 'input.foo_field');
+ assert.isVisible(form.$('input.foo_field'));
+
+ form.destroy();
+ });
+
+ QUnit.test('empty readonly fields are visible on new records', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.foo.readonly = true;
+ this.data.partner.fields.foo.default = undefined;
+ this.data.partner.records[0].foo = undefined;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet><group>' +
+ '<field name="foo"/>' +
+ '</group></sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, '.o_field_empty');
+
+ await testUtils.form.clickCreate(form);
+
+ assert.containsNone(form, '.o_field_empty');
+ form.destroy();
+ });
+
+ QUnit.test('all group children have correct layout classname', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet><group>' +
+ '<group class="inner_group">' +
+ '<field name="name"/>' +
+ '</group>' +
+ '<div class="inner_div">' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</group></sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.hasClass(form.$('.inner_group'), 'o_group_col_6'),
+ assert.hasClass(form.$('.inner_div'), 'o_group_col_6'),
+ form.destroy();
+ });
+
+ QUnit.test('deleting a record', async function (assert) {
+ assert.expect(8);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ viewOptions: {
+ ids: [1, 2, 4],
+ index: 0,
+ hasActionMenus: true,
+ },
+ res_id: 1,
+ });
+
+ assert.strictEqual(cpHelpers.getPagerValue(form), "1", 'pager value should be 1');
+ assert.strictEqual(cpHelpers.getPagerSize(form), "3", 'pager limit should be 3');
+ assert.strictEqual(form.$('span:contains(yop)').length, 1,
+ 'should have a field with foo value for record 1');
+ assert.ok(!$('.modal:visible').length, 'no confirm modal should be displayed');
+
+ // open action menu and delete
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Delete");
+
+ assert.ok($('.modal').length, 'a confirm modal should be displayed');
+
+ // confirm the delete
+ await testUtils.dom.click($('.modal-footer button.btn-primary'));
+
+ assert.strictEqual(cpHelpers.getPagerValue(form), "1", 'pager value should be 1');
+ assert.strictEqual(cpHelpers.getPagerSize(form), "2", 'pager limit should be 2');
+ assert.strictEqual(form.$('span:contains(blip)').length, 1,
+ 'should have a field with foo value for record 2');
+ form.destroy();
+ });
+
+ QUnit.test('deleting the last record', async function (assert) {
+ assert.expect(6);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners"><field name="foo"></field></form>',
+ viewOptions: {
+ ids: [1],
+ index: 0,
+ hasActionMenus: true,
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Delete");
+
+ await testUtils.mock.intercept(form, 'history_back', function () {
+ assert.step('history_back');
+ });
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer button.btn-primary'));
+ assert.strictEqual($('.modal').length, 0, 'no confirm modal should be displayed');
+
+ assert.verifySteps(['read', 'unlink', 'history_back']);
+ form.destroy();
+ });
+
+ QUnit.test('empty required fields cannot be saved', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.foo.required = true;
+ delete this.data.partner.fields.foo.default;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ services: {
+ notification: NotificationService.extend({
+ notify: function (params) {
+ if (params.type !== 'danger') {
+ return;
+ }
+ assert.strictEqual(params.title, 'Invalid fields:',
+ "should have a warning with correct title");
+ assert.strictEqual(params.message, '<ul><li>Foo</li></ul>',
+ "should have a warning with correct message");
+ }
+ }),
+ },
+ });
+
+ await testUtils.form.clickSave(form);
+ assert.hasClass(form.$('label.o_form_label'),'o_field_invalid');
+ assert.hasClass(form.$('input[name=foo]'),'o_field_invalid');
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ assert.containsNone(form, '.o_field_invalid');
+
+ form.destroy();
+ });
+
+ QUnit.test('changes in a readonly form view are saved directly', async function (assert) {
+ assert.expect(10);
+
+ var nbWrite = 0;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="priority" widget="priority"/>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ nbWrite++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+
+ assert.containsN(form, '.o_priority .o_priority_star', 2,
+ 'priority widget should have been rendered');
+ assert.containsN(form, '.o_priority .fa-star-o', 2,
+ 'priority widget should have been rendered with correct value');
+
+ // edit the value in readonly
+ await testUtils.dom.click(form.$('.o_priority .fa-star-o:first'));
+ assert.strictEqual(nbWrite, 1, 'should have saved directly');
+ assert.containsOnce(form, '.o_priority .fa-star',
+ 'priority widget should have been updated');
+
+ // switch to edit mode and edit the value again
+ await testUtils.form.clickEdit(form);
+ assert.containsN(form, '.o_priority .o_priority_star', 2,
+ 'priority widget should have been correctly rendered');
+ assert.containsOnce(form, '.o_priority .fa-star',
+ 'priority widget should have correct value');
+ await testUtils.dom.click(form.$('.o_priority .fa-star-o:first'));
+ assert.strictEqual(nbWrite, 1, 'should not have saved directly');
+ assert.containsN(form, '.o_priority .fa-star', 2,
+ 'priority widget should have been updated');
+
+ // save
+ await testUtils.form.clickSave(form);
+ assert.strictEqual(nbWrite, 2, 'should not have saved directly');
+ assert.containsN(form, '.o_priority .fa-star', 2,
+ 'priority widget should have correct value');
+ form.destroy();
+ });
+
+ QUnit.test('display a dialog if onchange result is a warning', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = { foo: true };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/><field name="int_field"/></group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({
+ value: { int_field: 10 },
+ warning: {
+ title: "Warning",
+ message: "You must first select a partner",
+ type: 'dialog',
+ }
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ warning: function (event) {
+ assert.strictEqual(event.data.type, 'dialog',
+ "should have triggered an event with the correct data");
+ assert.strictEqual(event.data.title, "Warning",
+ "should have triggered an event with the correct data");
+ assert.strictEqual(event.data.message, "You must first select a partner",
+ "should have triggered an event with the correct data");
+ },
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), '9');
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), '10');
+
+ form.destroy();
+ });
+
+ QUnit.test('display a notificaton if onchange result is a warning with type notification', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = { foo: true };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/><field name="int_field"/></group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({
+ value: { int_field: 10 },
+ warning: {
+ title: "Warning",
+ message: "You must first select a partner",
+ type: 'notification',
+ }
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ warning: function (event) {
+ assert.strictEqual(event.data.type, 'notification',
+ "should have triggered an event with the correct data");
+ assert.strictEqual(event.data.title, "Warning",
+ "should have triggered an event with the correct data");
+ assert.strictEqual(event.data.message, "You must first select a partner",
+ "should have triggered an event with the correct data");
+ },
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), '9');
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ assert.strictEqual(form.$('input[name=int_field]').val(), '10');
+
+ form.destroy();
+ });
+
+ QUnit.test('can create record even if onchange returns a warning', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = { foo: true };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/><field name="int_field"/></group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({
+ value: { int_field: 10 },
+ warning: {
+ title: "Warning",
+ message: "You must first select a partner"
+ }
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ warning: function (event) {
+ assert.ok(true, 'should trigger a warning');
+ },
+ },
+ });
+ assert.strictEqual(form.$('input[name="int_field"]').val(), "10",
+ "record should have been created and rendered");
+
+ form.destroy();
+ });
+
+ QUnit.test('do nothing if add a line in one2many result in a onchange with a warning', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = { foo: true };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({
+ value: {},
+ warning: {
+ title: "Warning",
+ message: "You must first select a partner",
+ }
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ warning: function () {
+ assert.step("should have triggered a warning");
+ },
+ },
+ });
+
+ // go to edit mode, click to add a record in the o2m
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ assert.containsNone(form, 'tr.o_data_row',
+ "should not have added a line");
+ assert.verifySteps(["should have triggered a warning"]);
+ form.destroy();
+ });
+
+ QUnit.test('button box is rendered in create mode', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<div name="button_box" class="oe_button_box">' +
+ '<button type="object" class="oe_stat_button" icon="fa-check-square">' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ // readonly mode
+ assert.containsOnce(form, '.oe_stat_button',
+ "button box should be displayed in readonly");
+
+ // edit mode
+ await testUtils.form.clickEdit(form);
+
+ assert.containsOnce(form, '.oe_stat_button',
+ "button box should be displayed in edit on an existing record");
+
+ // create mode (leave edition first!)
+ await testUtils.form.clickDiscard(form);
+ await testUtils.form.clickCreate(form);
+ assert.containsOnce(form, '.oe_stat_button',
+ "button box should be displayed when creating a new record as well");
+
+ form.destroy();
+ });
+
+ QUnit.test('properly apply onchange on one2many fields', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[0].p = [4];
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.p = [
+ [5],
+ [1, 4, {display_name: "updated record"}],
+ [0, null, {display_name: "created record"}],
+ ];
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, '.o_field_one2many .o_data_row',
+ "there should be one one2many record linked at first");
+ assert.strictEqual(form.$('.o_field_one2many .o_data_row td:first').text(), 'aaa',
+ "the 'display_name' of the one2many record should be correct");
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange');
+ var $o2m = form.$('.o_field_one2many');
+ assert.strictEqual($o2m.find('.o_data_row').length, 2,
+ "there should be two linked record");
+ assert.strictEqual($o2m.find('.o_data_row:first td:first').text(), 'updated record',
+ "the 'display_name' of the first one2many record should have been updated");
+ assert.strictEqual($o2m.find('.o_data_row:nth(1) td:first').text(), 'created record',
+ "the 'display_name' of the second one2many record should be correct");
+
+ form.destroy();
+ });
+
+ QUnit.test('properly apply onchange on one2many fields direct click', async function (assert) {
+ assert.expect(3);
+
+ var def = testUtils.makeTestPromise();
+
+ this.data.partner.records[0].p = [2, 4];
+ this.data.partner.onchanges = {
+ int_field: function (obj) {
+ obj.p = [
+ [5],
+ [1, 2, {display_name: "updated record 1", int_field: obj.int_field}],
+ [1, 4, {display_name: "updated record 2", int_field: obj.int_field * 2}],
+ ];
+ },
+ };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</group>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ var self = this;
+ var my_args = arguments;
+ var my_super = this._super;
+ return def.then(() => {
+ return my_super.apply(self, my_args)
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ archs: {
+ 'partner,false,form': '<form><group><field name="display_name"/><field name="int_field"/></group></form>'
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ // Trigger the onchange
+ await testUtils.fields.editInput(form.$('input[name=int_field]'), '2');
+
+ // Open first record in one2many
+ await testUtils.dom.click(form.$('.o_data_row:first'));
+
+ assert.containsNone(document.body, '.modal');
+
+ def.resolve();
+ await testUtils.nextTick();
+
+ assert.containsOnce(document.body, '.modal');
+ assert.strictEqual($('.modal').find('input[name=int_field]').val(), '2');
+
+ form.destroy();
+ });
+
+ QUnit.test('update many2many value in one2many after onchange', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[1].p = [4];
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.p = [
+ [5],
+ [1, 4, {
+ display_name: "gold",
+ timmy: [[5]],
+ }],
+ ];
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name" attrs="{\'readonly\': [(\'timmy\', \'=\', false)]}"/>' +
+ '<field name="timmy"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 2,
+ });
+ assert.strictEqual($('div[name="p"] .o_data_row td').text().trim(), "aaaNo records",
+ "should have proper initial content");
+ await testUtils.form.clickEdit(form);
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
+
+ assert.strictEqual($('div[name="p"] .o_data_row td').text().trim(), "goldNo records",
+ "should have proper initial content");
+ form.destroy();
+ });
+
+ QUnit.test('delete a line in a one2many while editing another line triggers a warning', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].p = [1, 2];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name" required="True"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_data_cell').first());
+ await testUtils.fields.editInput(form.$('input[name=display_name]'), '');
+ await testUtils.dom.click(form.$('.fa-trash-o').eq(1));
+
+ assert.strictEqual($('.modal').find('.modal-title').first().text(), "Warning",
+ "Clicking out of a dirty line while editing should trigger a warning modal.");
+
+ await testUtils.dom.click($('.modal').find('.btn-primary'));
+ // use of owlCompatibilityNextTick because there are two sequential updates of the
+ // control panel (which is written in owl): each of them waits for the next animation frame
+ // to complete
+ await testUtils.owlCompatibilityNextTick();
+ assert.strictEqual(form.$('.o_data_cell').first().text(), "first record",
+ "Value should have been reset to what it was before editing began.");
+ assert.containsOnce(form, '.o_data_row',
+ "The other line should have been deleted.");
+ form.destroy();
+ });
+
+ QUnit.test('properly apply onchange on many2many fields', async function (assert) {
+ assert.expect(14);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.timmy = [
+ [5],
+ [4, 12],
+ [4, 14],
+ ];
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '<field name="timmy">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'read' && args.model === 'partner_type') {
+ assert.deepEqual(args.args[0], [12, 14],
+ "should read both m2m with one RPC");
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].timmy, [[6, false, [12, 14]]],
+ "should correctly save the changed m2m values");
+
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 2,
+ });
+
+ assert.containsNone(form, '.o_field_many2many .o_data_row',
+ "there should be no many2many record linked at first");
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange');
+ var $m2m = form.$('.o_field_many2many');
+ assert.strictEqual($m2m.find('.o_data_row').length, 2,
+ "there should be two linked records");
+ assert.strictEqual($m2m.find('.o_data_row:first td:first').text(), 'gold',
+ "the 'display_name' of the first m2m record should be correctly displayed");
+ assert.strictEqual($m2m.find('.o_data_row:nth(1) td:first').text(), 'silver',
+ "the 'display_name' of the second m2m record should be correctly displayed");
+
+ await testUtils.form.clickSave(form);
+
+ assert.verifySteps(['read', 'onchange', 'read', 'write', 'read', 'read']);
+
+ form.destroy();
+ });
+
+ QUnit.test('display_name not sent for onchanges if not in view', async function (assert) {
+ assert.expect(7);
+
+ this.data.partner.records[0].timmy = [12];
+ this.data.partner.onchanges = {
+ foo: function () {},
+ };
+ this.data.partner_type.onchanges = {
+ name: function () {},
+ };
+ var readInModal = false;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="timmy">' +
+ '<tree>' +
+ '<field name="name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="name"/>' +
+ '<field name="color"/>' +
+ '</form>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read' && args.model === 'partner') {
+ assert.deepEqual(args.args[1], ['foo', 'timmy', 'display_name'],
+ "should read display_name even if not in the view");
+ }
+ if (args.method === 'read' && args.model === 'partner_type') {
+ if (!readInModal) {
+ assert.deepEqual(args.args[1], ['name'],
+ "should not read display_name for records in the list");
+ } else {
+ assert.deepEqual(args.args[1], ['name', 'color', 'display_name'],
+ "should read display_name when opening the subrecord");
+ }
+ }
+ if (args.method === 'onchange' && args.model === 'partner') {
+ assert.deepEqual(args.args[1], {
+ id: 1,
+ foo: 'coucou',
+ timmy: [[6, false, [12]]],
+ }, "should only send the value of fields in the view (+ id)");
+ assert.deepEqual(args.args[3], {
+ foo: '1',
+ timmy: '',
+ 'timmy.name': '1',
+ 'timmy.color': '',
+ }, "only the fields in the view should be in the onchange spec");
+ }
+ if (args.method === 'onchange' && args.model === 'partner_type') {
+ assert.deepEqual(args.args[1], {
+ id: 12,
+ name: 'new name',
+ color: 2,
+ }, "should only send the value of fields in the view (+ id)");
+ assert.deepEqual(args.args[3], {
+ name: '1',
+ color: '',
+ }, "only the fields in the view should be in the onchange spec");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // trigger the onchange
+ await testUtils.fields.editInput(form.$('.o_field_widget[name=foo]'), "coucou");
+
+ // open a subrecord and trigger an onchange
+ readInModal = true;
+ await testUtils.dom.click(form.$('.o_data_row .o_data_cell:first'));
+ await testUtils.fields.editInput($('.modal .o_field_widget[name=name]'), "new name");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchanges on date(time) fields', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.date = '2021-12-12';
+ obj.datetime = '2021-12-12 10:55:05';
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_widget[name=date]').text(),
+ '01/25/2017', "the initial date should be correct");
+ assert.strictEqual(form.$('.o_field_widget[name=datetime]').text(),
+ '12/12/2016 12:55:05', "the initial datetime should be correct");
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('.o_field_widget[name=date] input').val(),
+ '01/25/2017', "the initial date should be correct in edit");
+ assert.strictEqual(form.$('.o_field_widget[name=datetime] input').val(),
+ '12/12/2016 12:55:05', "the initial datetime should be correct in edit");
+
+ // trigger the onchange
+ await testUtils.fields.editInput(form.$('.o_field_widget[name="foo"]'), "coucou");
+
+ assert.strictEqual(form.$('.o_field_widget[name=date] input').val(),
+ '12/12/2021', "the initial date should be correct in edit");
+ assert.strictEqual(form.$('.o_field_widget[name=datetime] input').val(),
+ '12/12/2021 12:55:05', "the initial datetime should be correct in edit");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchanges are not sent for each keystrokes', async function (assert) {
+ var done = assert.async();
+ assert.expect(5);
+
+ var onchangeNbr = 0;
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length + 1000;
+ },
+ };
+ var def = testUtils.makeTestPromise();
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group><field name="foo"/><field name="int_field"/></group>' +
+ '</form>',
+ res_id: 2,
+ fieldDebounce: 3,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ onchangeNbr++;
+ return concurrency.delay(3).then(function () {
+ def.resolve();
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ testUtils.fields.editInput(form.$('input[name=foo]'), '1');
+ assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet");
+ testUtils.fields.editInput(form.$('input[name=foo]'), '12');
+ assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet");
+
+ return waitForFinishedOnChange().then(async function () {
+ assert.strictEqual(onchangeNbr, 1, "one onchange has been called");
+
+ // add something in the input, then focus another input
+ await testUtils.fields.editAndTrigger(form.$('input[name=foo]'), '123', ['change']);
+ assert.strictEqual(onchangeNbr, 2, "one onchange has been called immediately");
+
+ return waitForFinishedOnChange();
+ }).then(function () {
+ assert.strictEqual(onchangeNbr, 2, "no extra onchange should have been called");
+
+ form.destroy();
+ done();
+ });
+
+ function waitForFinishedOnChange() {
+ return def.then(function () {
+ def = testUtils.makeTestPromise();
+ return concurrency.delay(0);
+ });
+ }
+ });
+
+ QUnit.test('onchanges are not sent for invalid values', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.onchanges = {
+ int_field: function (obj) {
+ obj.foo = String(obj.int_field);
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group><field name="foo"/><field name="int_field"/></group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ // edit int_field, and check that an onchange has been applied
+ await testUtils.fields.editInput(form.$('input[name="int_field"]'), "123");
+ assert.strictEqual(form.$('input[name="foo"]').val(), "123",
+ "the onchange has been applied");
+
+ // enter an invalid value in a float, and check that no onchange has
+ // been applied
+ await testUtils.fields.editInput(form.$('input[name="int_field"]'), "123a");
+ assert.strictEqual(form.$('input[name="foo"]').val(), "123",
+ "the onchange has not been applied");
+
+ // save, and check that the int_field input is marked as invalid
+ await testUtils.form.clickSave(form);
+ assert.hasClass(form.$('input[name="int_field"]'),'o_field_invalid',
+ "input int_field is marked as invalid");
+
+ assert.verifySteps(['read', 'onchange']);
+ form.destroy();
+ });
+
+ QUnit.test('rpc complete after destroying parent', async function (assert) {
+ // We just test that there is no crash in this situation
+ assert.expect(0);
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<button name="update_module" type="object" class="o_form_button_update"/>' +
+ '</form>',
+ res_id: 2,
+ intercepts: {
+ execute_action: function (event) {
+ form.destroy();
+ event.data.on_success();
+ }
+ }
+ });
+ await testUtils.dom.click(form.$('.o_form_button_update'));
+ });
+
+ QUnit.test('onchanges that complete after discarding', async function (assert) {
+ assert.expect(5);
+
+ var def1 = testUtils.makeTestPromise();
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length + 1000;
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group><field name="foo"/><field name="int_field"/></group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ assert.step('onchange is done');
+ return def1.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ // go into edit mode
+ assert.strictEqual(form.$('span[name="foo"]').text(), "blip",
+ "field foo should be displayed to initial value");
+ await testUtils.form.clickEdit(form);
+
+ // edit a value
+ await testUtils.fields.editInput(form.$('input[name=foo]'), '1234');
+
+ // discard changes
+ await testUtils.form.clickDiscard(form);
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+ assert.strictEqual(form.$('span[name="foo"]').text(), "blip",
+ "field foo should still be displayed to initial value");
+
+ // complete the onchange
+ def1.resolve();
+ await testUtils.nextTick();
+ assert.strictEqual(form.$('span[name="foo"]').text(), "blip",
+ "field foo should still be displayed to initial value");
+ assert.verifySteps(['onchange is done']);
+
+ form.destroy();
+ });
+
+ QUnit.test('discarding before save returns', async function (assert) {
+ assert.expect(4);
+
+ var def = testUtils.makeTestPromise();
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'write') {
+ return def.then(_.constant(result));
+ }
+ return result;
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), '1234');
+
+ // save the value and discard directly
+ await testUtils.form.clickSave(form);
+ form.discardChanges(); // Simulate click on breadcrumb
+
+ assert.strictEqual(form.$('.o_field_widget[name="foo"]').val(), "1234",
+ "field foo should still contain new value");
+ assert.strictEqual($('.modal').length, 0,
+ "Confirm dialog should not be displayed");
+
+ // complete the write
+ def.resolve();
+ await testUtils.nextTick();
+ assert.strictEqual($('.modal').length, 0,
+ "Confirm dialog should not be displayed");
+ assert.strictEqual(form.$('.o_field_widget[name="foo"]').text(), "1234",
+ "value should have been saved and rerendered in readonly");
+
+ form.destroy();
+ });
+
+ QUnit.test('unchanged relational data is sent for onchanges', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[1].p = [4];
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length + 1000;
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.deepEqual(args.args[1].p, [[4, 4, false]],
+ "should send a command for field p even if it hasn't changed");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange');
+
+ form.destroy();
+ });
+
+ QUnit.test('onchanges on unknown fields of o2m are ignored', async function (assert) {
+ // many2one fields need to be postprocessed (the onchange returns [id,
+ // display_name]), but if we don't know the field, we can't know it's a
+ // many2one, so it isn't ignored, its value is an array instead of a
+ // dataPoint id, which may cause errors later (e.g. when saving).
+ assert.expect(2);
+
+ this.data.partner.records[1].p = [4];
+ this.data.partner.onchanges = {
+ foo: function () {},
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="foo"/>' +
+ '<field name="product_id"/>' +
+ '</form>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({
+ value: {
+ p: [
+ [5],
+ [1, 4, {
+ foo: 'foo changed',
+ product_id: [37, "xphone"],
+ }]
+ ],
+ },
+ });
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].p, [[1, 4, {
+ foo: 'foo changed',
+ }]], "should only write value of known fields");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange');
+ await testUtils.owlCompatibilityNextTick();
+
+ assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed',
+ "onchange should have been correctly applied on field in o2m list");
+
+ await testUtils.form.clickSave(form);
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange value are not discarded on o2m edition', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[1].p = [4];
+ this.data.partner.onchanges = {
+ foo: function () {},
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="foo"/>' +
+ '<field name="product_id"/>' +
+ '</form>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({
+ value: {
+ p: [[5], [1, 4, {foo: 'foo changed'}]],
+ },
+ });
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].p, [[1, 4, {
+ foo: 'foo changed',
+ }]], "should only write value of known fields");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value',
+ "the initial value should be the default one");
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange');
+ await testUtils.owlCompatibilityNextTick();
+
+ assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed',
+ "onchange should have been correctly applied on field in o2m list");
+
+ await testUtils.dom.click(form.$('.o_data_row'));
+ assert.strictEqual($('.modal .modal-title').text().trim(), 'Open: one2many field',
+ "the field string is displayed in the modal title");
+ assert.strictEqual($('.modal .o_field_widget').val(), 'foo changed',
+ "the onchange value hasn't been discarded when opening the o2m");
+
+ form.destroy();
+ });
+
+ QUnit.test('args of onchanges in o2m fields are correct (inline edition)', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[1].p = [4];
+ this.data.partner.fields.p.relation_field = 'rel_field';
+ this.data.partner.fields.int_field.default = 14;
+ this.data.partner.onchanges = {
+ int_field: function (obj) {
+ obj.foo = '[' + obj.rel_field.foo + '] ' + obj.int_field;
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value',
+ "the initial value should be the default one");
+
+ await testUtils.dom.click(form.$('.o_data_row td:nth(1)'));
+ await testUtils.fields.editInput(form.$('.o_data_row input:nth(1)'), 77);
+
+ assert.strictEqual(form.$('.o_data_row input:first').val(), '[blip] 77',
+ "onchange should have been correctly applied");
+
+ // create a new o2m record
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ assert.strictEqual(form.$('.o_data_row input:first').val(), '[blip] 14',
+ "onchange should have been correctly applied after default get");
+
+ form.destroy();
+ });
+
+ QUnit.test('args of onchanges in o2m fields are correct (dialog edition)', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records[1].p = [4];
+ this.data.partner.fields.p.relation_field = 'rel_field';
+ this.data.partner.fields.int_field.default = 14;
+ this.data.partner.onchanges = {
+ int_field: function (obj) {
+ obj.foo = '[' + obj.rel_field.foo + '] ' + obj.int_field;
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="p" string="custom label">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</form>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value',
+ "the initial value should be the default one");
+
+ await testUtils.dom.click(form.$('.o_data_row td:first'));
+ await testUtils.nextTick();
+ await testUtils.fields.editInput($('.modal input:nth(1)'), 77);
+ assert.strictEqual($('.modal input:first').val(), '[blip] 77',
+ "onchange should have been correctly applied");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+ assert.strictEqual(form.$('.o_data_row td:first').text(), '[blip] 77',
+ "onchange should have been correctly applied");
+
+ // create a new o2m record
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ assert.strictEqual($('.modal .modal-title').text().trim(), 'Create custom label',
+ "the custom field label is applied in the modal title");
+ assert.strictEqual($('.modal input:first').val(), '[blip] 14',
+ "onchange should have been correctly applied after default get");
+ await testUtils.dom.clickFirst($('.modal-footer .btn-primary'));
+ await testUtils.nextTick();
+ assert.strictEqual(form.$('.o_data_row:nth(1) td:first').text(), '[blip] 14',
+ "onchange should have been correctly applied after default get");
+
+ form.destroy();
+ });
+
+ QUnit.test('context of onchanges contains the context of changed fields', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ foo: function () {},
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="foo" context="{\'test\': 1}"/>' +
+ '<field name="int_field" context="{\'int_ctx\': 1}"/>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.strictEqual(args.kwargs.context.test, 1,
+ "the context of the field triggering the onchange should be given");
+ assert.strictEqual(args.kwargs.context.int_ctx, undefined,
+ "the context of other fields should not be given");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 2,
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'coucou');
+
+ form.destroy();
+ });
+
+ QUnit.test('navigation with tab key in form view', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" widget="email"/>' +
+ '<field name="bar"/>' +
+ '<field name="display_name" widget="url"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ // go to edit mode
+ await testUtils.form.clickEdit(form);
+
+ // focus first input, trigger tab
+ form.$('input[name="foo"]').focus();
+
+ const tabKey = { keyCode: $.ui.keyCode.TAB, which: $.ui.keyCode.TAB };
+ await testUtils.dom.triggerEvent(form.$('input[name="foo"]'), 'keydown', tabKey);
+ assert.ok($.contains(form.$('div[name="bar"]')[0], document.activeElement),
+ "bar checkbox should be focused");
+
+ await testUtils.dom.triggerEvent(document.activeElement, 'keydown', tabKey);
+ assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
+ "display_name should be focused");
+
+ // simulate shift+tab on active element
+ const shiftTabKey = Object.assign({}, tabKey, { shiftKey: true });
+ await testUtils.dom.triggerEvent(document.activeElement, 'keydown', shiftTabKey);
+ await testUtils.dom.triggerEvent(document.activeElement, 'keydown', shiftTabKey);
+ assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
+ "first input should be focused");
+
+ form.destroy();
+ });
+
+ QUnit.test('navigation with tab key in readonly form view', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[1].product_id = 37;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="trululu"/>' +
+ '<field name="foo"/>' +
+ '<field name="product_id"/>' +
+ '<field name="foo" widget="phone"/>' +
+ '<field name="display_name" widget="url"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ // focus first field, trigger tab
+ form.$('[name="trululu"]').focus();
+ form.$('[name="trululu"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ form.$('[name="foo"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$('[name="product_id"]')[0], document.activeElement,
+ "product_id should be focused");
+ form.$('[name="product_id"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ form.$('[name="foo"]:eq(1)').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$('[name="display_name"]')[0], document.activeElement,
+ "display_name should be focused");
+
+ // simulate shift+tab on active element
+ $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ assert.strictEqual(document.activeElement, form.$('[name="trululu"]')[0],
+ "first many2one should be focused");
+
+ form.destroy();
+ });
+
+ QUnit.test('skip invisible fields when navigating with TAB', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].bar = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet><group>' +
+ '<field name="foo"/>' +
+ '<field name="bar" invisible="1"/>' +
+ '<field name="product_id" attrs=\'{"invisible": [["bar", "=", true]]}\'/>' +
+ '<field name="int_field"/>' +
+ '</group></sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ form.$('input[name="foo"]').focus();
+ form.$('input[name="foo"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$('input[name="int_field"]')[0], document.activeElement,
+ "int_field should be focused");
+
+ form.destroy();
+ });
+
+ QUnit.test('navigation with tab key selects a value in form view', async function (assert) {
+ assert.expect(5);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="display_name"/>
+ <field name="int_field"/>
+ <field name="qux"/>
+ <field name="trululu"/>
+ <field name="date"/>
+ <field name="datetime"/>
+ </form>`,
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ await testUtils.dom.click(form.el.querySelector('input[name="display_name"]'));
+ await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
+ assert.strictEqual(document.getSelection().toString(), "10",
+ "int_field value should be selected");
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
+ assert.strictEqual(document.getSelection().toString(), "0.4",
+ "qux field value should be selected");
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
+ assert.strictEqual(document.getSelection().toString(), "aaa",
+ "trululu field value should be selected");
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
+ assert.strictEqual(document.getSelection().toString(), "01/25/2017",
+ "date field value should be selected");
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
+ assert.strictEqual(document.getSelection().toString(), "12/12/2016 10:55:05",
+ "datetime field value should be selected");
+
+ form.destroy();
+ });
+
+ QUnit.test('clicking on a stat button with a context', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<sheet>' +
+ '<div class="oe_button_box" name="button_box">' +
+ '<button class="oe_stat_button" type="action" name="1" context="{\'test\': active_id}">' +
+ '<field name="qux" widget="statinfo"/>' +
+ '</button>' +
+ '</div>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ viewOptions: {
+ context: {some_context: true},
+ },
+ intercepts: {
+ execute_action: function (e) {
+ assert.deepEqual(e.data.action_data.context, {
+ 'test': 2
+ }, "button context should have been evaluated and given to the action, with magicc without previous context");
+ },
+ },
+ });
+
+ await testUtils.dom.click(form.$('.oe_stat_button'));
+
+ form.destroy();
+ });
+
+ QUnit.test('clicking on a stat button with no context', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<sheet>' +
+ '<div class="oe_button_box" name="button_box">' +
+ '<button class="oe_stat_button" type="action" name="1">' +
+ '<field name="qux" widget="statinfo"/>' +
+ '</button>' +
+ '</div>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ viewOptions: {
+ context: {some_context: true},
+ },
+ intercepts: {
+ execute_action: function (e) {
+ assert.deepEqual(e.data.action_data.context, {
+ }, "button context should have been evaluated and given to the action, with magic keys but without previous context");
+ },
+ },
+ });
+
+ await testUtils.dom.click(form.$('.oe_stat_button'));
+
+ form.destroy();
+ });
+
+ QUnit.test('diplay a stat button outside a buttonbox', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<sheet>' +
+ '<button class="oe_stat_button" type="action" name="1">' +
+ '<field name="int_field" widget="statinfo"/>' +
+ '</button>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.containsOnce(form, 'button .o_field_widget',
+ "a field widget should be display inside the button");
+ assert.strictEqual(form.$('button .o_field_widget').children().length, 2,
+ "the field widget should have 2 children, the text and the value");
+ assert.strictEqual(parseInt(form.$('button .o_field_widget .o_stat_value').text()), 9,
+ "the value rendered should be the same as the field value");
+ form.destroy();
+ });
+
+ QUnit.test('diplay something else than a button in a buttonbox', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<div name="button_box" class="oe_button_box">' +
+ '<button type="object" class="oe_stat_button" icon="fa-check-square">' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '<label/>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.strictEqual(form.$('.oe_button_box').children().length, 2,
+ "button box should contain two children");
+ assert.containsOnce(form, '.oe_button_box > .oe_stat_button',
+ "button box should only contain one button");
+ assert.containsOnce(form, '.oe_button_box > label',
+ "button box should only contain one label");
+
+ form.destroy();
+ });
+
+ QUnit.test('invisible fields are not considered as visible in a buttonbox', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<div name="button_box" class="oe_button_box">' +
+ '<field name="foo" invisible="1"/>' +
+ '<field name="bar" invisible="1"/>' +
+ '<field name="int_field" invisible="1"/>' +
+ '<field name="qux" invisible="1"/>' +
+ '<field name="display_name" invisible="1"/>' +
+ '<field name="state" invisible="1"/>' +
+ '<field name="date" invisible="1"/>' +
+ '<field name="datetime" invisible="1"/>' +
+ '<button type="object" class="oe_stat_button" icon="fa-check-square"/>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ assert.strictEqual(form.$('.oe_button_box').children().length, 1,
+ "button box should contain only one child");
+ assert.hasClass(form.$('.oe_button_box'), 'o_not_full',
+ "the buttonbox should not be full");
+
+ form.destroy();
+ });
+
+ QUnit.test('display correctly buttonbox, in large size class', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<div name="button_box" class="oe_button_box">' +
+ '<button type="object" class="oe_stat_button" icon="fa-check-square">' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '<button type="object" class="oe_stat_button" icon="fa-check-square">' +
+ '<field name="foo"/>' +
+ '</button>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ config: {
+ device: {size_class: 5},
+ },
+ });
+
+ assert.strictEqual(form.$('.oe_button_box').children().length, 2,
+ "button box should contain two children");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many default value creation', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].product_ids = [37];
+ this.data.partner.fields.product_ids.default = [
+ [0, 0, { name: 'xdroid', partner_type_id: 12 }]
+ ];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="product_ids" nolabel="1">' +
+ '<tree editable="top" create="0">' +
+ '<field name="name" readonly="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ var command = args.args[0].product_ids[0];
+ assert.strictEqual(command[2].partner_type_id, 12,
+ "the default partner_type_id should be equal to 12");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ await testUtils.form.clickSave(form);
+ form.destroy();
+ });
+
+ QUnit.test('many2manys inside one2manys are saved correctly', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="timmy" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ var command = args.args[0].p;
+ assert.deepEqual(command, [[0, command[0][1], {
+ timmy: [[6, false, [12]]],
+ }]], "the default partner_type_id should be equal to 12");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // add a o2m subrecord with a m2m tag
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.many2one.clickOpenDropdown('timmy');
+ await testUtils.fields.many2one.clickHighlightedItem('timmy');
+
+ await testUtils.form.clickSave(form);
+
+ form.destroy();
+ });
+
+ QUnit.test('one2manys (list editable) inside one2manys are saved correctly', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="p"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ "partner,false,form": '<form>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>'
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0].p,
+ [[0, args.args[0].p[0][1], {
+ p: [[0, args.args[0].p[0][2].p[0][1], {display_name: "xtv"}]],
+ }]],
+ "create should be called with the correct arguments");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // add a o2m subrecord
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.dom.click($('.modal-body .o_field_one2many .o_field_x2many_list_row_add a'));
+ await testUtils.fields.editInput($('.modal-body input'), 'xtv');
+ await testUtils.dom.click($('.modal-footer button:first'));
+ assert.strictEqual($('.modal').length, 0,
+ "dialog should be closed");
+
+ var row = form.$('.o_field_one2many .o_list_view .o_data_row');
+ assert.strictEqual(row.children()[0].textContent, '1 record',
+ "the cell should contains the number of record: 1");
+
+ await testUtils.form.clickSave(form);
+
+ form.destroy();
+ });
+
+ QUnit.test('oe_read_only and oe_edit_only classNames on fields inside groups', async function (assert) {
+ assert.expect(10);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="foo" class="oe_read_only"/>
+ <field name="bar" class="oe_edit_only"/>
+ </group>
+ </form>`,
+ res_id: 1,
+ });
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_readonly',
+ 'form should be in readonly mode');
+ assert.isVisible(form.$('.o_field_widget[name=foo]'));
+ assert.isVisible(form.$('label:contains(Foo)'));
+ assert.isNotVisible(form.$('.o_field_widget[name=bar]'));
+ assert.isNotVisible(form.$('label:contains(Bar)'));
+
+ await testUtils.form.clickEdit(form);
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable',
+ 'form should be in readonly mode');
+ assert.isNotVisible(form.$('.o_field_widget[name=foo]'));
+ assert.isNotVisible(form.$('label:contains(Foo)'));
+ assert.isVisible(form.$('.o_field_widget[name=bar]'));
+ assert.isVisible(form.$('label:contains(Bar)'));
+
+ form.destroy();
+ });
+
+ QUnit.test('oe_read_only className is handled in list views', async function (assert) {
+ assert.expect(7);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="display_name" class="oe_read_only"/>' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_readonly',
+ 'form should be in readonly mode');
+ assert.isVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'),
+ 'display_name cell should be visible in readonly mode');
+
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(form.el.querySelector('th[data-name="foo"]').style.width, '100%',
+ 'As the only visible char field, "foo" should take 100% of the remaining space');
+ assert.strictEqual(form.el.querySelector('th.oe_read_only').style.width, '0px',
+ '"oe_read_only" in edit mode should have a 0px width');
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable',
+ 'form should be in edit mode');
+ assert.isNotVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'),
+ 'display_name cell should not be visible in edit mode');
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.owlCompatibilityNextTick();
+ assert.hasClass(form.$('.o_form_view .o_list_view tbody tr:first input[name="display_name"]'),
+ 'oe_read_only', 'display_name input should have oe_read_only class');
+
+ form.destroy();
+ });
+
+ QUnit.test('oe_edit_only className is handled in list views', async function (assert) {
+ assert.expect(5);
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="display_name" class="oe_edit_only"/>' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_readonly',
+ 'form should be in readonly mode');
+ assert.isNotVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'),
+ 'display_name cell should not be visible in readonly mode');
+
+ await testUtils.form.clickEdit(form);
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable',
+ 'form should be in edit mode');
+ assert.isVisible(form.$('.o_field_one2many .o_list_view thead th[data-name="display_name"]'),
+ 'display_name cell should be visible in edit mode');
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.owlCompatibilityNextTick();
+ assert.hasClass(form.$('.o_form_view .o_list_view tbody tr:first input[name="display_name"]'),
+ 'oe_edit_only', 'display_name input should have oe_edit_only class');
+
+ form.destroy();
+ });
+
+ QUnit.test('*_view_ref in context are passed correctly', async function (assert) {
+ var done = assert.async();
+ assert.expect(3);
+
+ createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p" context="{\'tree_view_ref\':\'module.tree_view_ref\'}"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ load_views: function (event) {
+ var context = event.data.context;
+ assert.strictEqual(context.tree_view_ref, 'module.tree_view_ref',
+ "context should contain tree_view_ref");
+ event.data.on_success();
+ }
+ },
+ viewOptions: {
+ context: {some_context: false},
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'read') {
+ assert.strictEqual('some_context' in args.kwargs.context && !args.kwargs.context.some_context, true,
+ "the context should have been set");
+ }
+ return this._super.apply(this, arguments);
+ },
+ }).then(async function (form) {
+ // reload to check that the record's context hasn't been modified
+ await form.reload();
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('non inline subview and create=0 in action context', async function (assert) {
+ // the create=0 should apply on the main view (form), but not on subviews
+ assert.expect(2);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="product_ids" mode="kanban"/></form>',
+ archs: {
+ "product,false,kanban": `<kanban>
+ <templates><t t-name="kanban-box">
+ <div><field name="name"/></div>
+ </t></templates>
+ </kanban>`,
+ },
+ res_id: 1,
+ viewOptions: {
+ context: {create: false},
+ mode: 'edit',
+ },
+ });
+
+ assert.containsNone(form, '.o_form_button_create');
+ assert.containsOnce(form, '.o-kanban-button-new');
+
+ form.destroy();
+ });
+
+ QUnit.test('readonly fields with modifiers may be saved', async function (assert) {
+ // the readonly property on the field description only applies on view,
+ // this is not a DB constraint. It should be seen as a default value,
+ // that may be overridden in views, for example with modifiers. So
+ // basically, a field defined as readonly may be edited.
+ assert.expect(3);
+
+ this.data.partner.fields.foo.readonly = true;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo" attrs="{\'readonly\': [(\'bar\',\'=\',False)]}"/>' +
+ '<field name="bar"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {foo: 'New foo value'},
+ "the new value should be saved");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // bar being set to true, foo shouldn't be readonly and thus its value
+ // could be saved, even if in its field description it is readonly
+ await testUtils.form.clickEdit(form);
+
+ assert.containsOnce(form, 'input[name="foo"]',
+ "foo field should be editable");
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), 'New foo value');
+
+ await testUtils.form.clickSave(form);
+
+ assert.strictEqual(form.$('.o_field_widget[name=foo]').text(), 'New foo value',
+ "new value for foo field should have been saved");
+
+ form.destroy();
+ });
+
+ QUnit.test('readonly set by modifier do not break many2many_tags', async function (assert) {
+ assert.expect(0);
+
+ this.data.partner.onchanges = {
+ bar: function (obj) {
+ obj.timmy = [[6, false, [12]]];
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="bar"/>' +
+ '<field name="timmy" widget="many2many_tags" attrs="{\'readonly\': [(\'bar\',\'=\',True)]}"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 5,
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_field_widget[name=bar] input'));
+
+ form.destroy();
+ });
+
+ QUnit.test('check if id and active_id are defined', async function (assert) {
+ assert.expect(2);
+
+ let checkOnchange = false;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p" context="{\'default_trululu\':active_id, \'current_id\':id}">' +
+ '<tree>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ "partner,false,form": '<form><field name="trululu"/></form>'
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange' && checkOnchange) {
+ assert.strictEqual(args.kwargs.context.current_id, false,
+ "current_id should be false");
+ assert.strictEqual(args.kwargs.context.default_trululu, false,
+ "default_trululu should be false");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ checkOnchange = true;
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ form.destroy();
+ });
+
+ QUnit.test('modifiers are considered on multiple <footer/> tags', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form>' +
+ '<field name="bar"/>' +
+ '<footer attrs="{\'invisible\': [(\'bar\',\'=\',False)]}">' +
+ '<button>Hello</button>' +
+ '<button>World</button>' +
+ '</footer>' +
+ '<footer attrs="{\'invisible\': [(\'bar\',\'!=\',False)]}">' +
+ '<button>Foo</button>' +
+ '</footer>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ footerToButtons: true,
+ mode: 'edit',
+ },
+ });
+
+ assert.deepEqual(getVisibleButtonTexts(), ["Hello", "World"],
+ "only the first button section should be visible");
+
+ await testUtils.dom.click(form.$(".o_field_boolean input"));
+
+ assert.deepEqual(getVisibleButtonTexts(), ["Foo"],
+ "only the second button section should be visible");
+
+ form.destroy();
+
+ function getVisibleButtonTexts() {
+ var $visibleButtons = form.$buttons.find('button:visible');
+ return _.map($visibleButtons, function (el) {
+ return el.innerHTML.trim();
+ });
+ }
+ });
+
+ QUnit.test('buttons in footer are moved to $buttons if necessary', async function (assert) {
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<footer>' +
+ '<button string="Create" type="object" class="infooter"/>' +
+ '</footer>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {footerToButtons: true},
+ });
+
+ assert.containsOnce(form.$('.o_control_panel'), 'button.infooter');
+ assert.containsNone(form.$('.o_form_view'), 'button.infooter');
+
+ // check that this still works after a reload
+ await testUtils.form.reload(form);
+
+ assert.containsOnce(form.$('.o_control_panel'), 'button.infooter');
+ assert.containsNone(form.$('.o_form_view'), 'button.infooter');
+
+ form.destroy();
+ });
+
+ QUnit.test('open new record even with warning message', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = { foo: true };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return Promise.resolve({
+ warning: {
+ title: "Warning",
+ message: "Any warning."
+ }
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ });
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(form.$('input').val(), 'blip', 'input should contain record value');
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), "tralala");
+ assert.strictEqual(form.$('input').val(), 'tralala', 'input should contain new value');
+
+ await form.reload({ currentId: false });
+ assert.strictEqual(form.$('input').val(), '',
+ 'input should have no value after reload');
+
+ form.destroy();
+ });
+
+ QUnit.test('render stat button with string inline', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ res_id: 1,
+ data: this.data,
+ arch: '<form string="Manufacturing Orders">' +
+ '<sheet>' +
+ '<div class="oe_button_box" name="button_box">' +
+ '<button string="Inventory Moves" class="oe_stat_button" icon="fa-arrows-v"/>' +
+ '</div>' +
+ '</sheet>' +
+ '</form>',
+ });
+ var $button = form.$('.o_form_view .o_form_sheet .oe_button_box .oe_stat_button span');
+ assert.strictEqual($button.text(), "Inventory Moves",
+ "the stat button should contain a span with the string attribute value");
+ form.destroy();
+ });
+
+ QUnit.test('renderer waits for asynchronous fields rendering', async function (assert) {
+ assert.expect(1);
+ var done = assert.async();
+
+ testUtils.createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="bar"/>' +
+ '<field name="foo" widget="ace"/>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ res_id: 1,
+ }).then(function (form) {
+ assert.containsOnce(form, '.ace_editor',
+ "should have waited for ace to load its dependencies");
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('open one2many form containing one2many', async function (assert) {
+ assert.expect(9);
+
+ this.data.partner.records[0].product_ids = [37];
+ this.data.product.fields.partner_type_ids = {
+ string: "one2many partner", type: "one2many", relation: "partner_type",
+ };
+ this.data.product.records[0].partner_type_ids = [12];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ res_id: 1,
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="product_ids">' +
+ '<tree create="0">' +
+ '<field name="display_name"/>' +
+ '<field name="partner_type_ids"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'product,false,form':
+ '<form string="Products">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="partner_type_ids">' +
+ '<tree create="0">' +
+ '<field name="display_name"/>' +
+ '<field name="color"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+ var row = form.$('.o_field_one2many .o_list_view .o_data_row');
+ assert.strictEqual(row.children()[1].textContent, '1 record',
+ "the cell should contains the number of record: 1");
+ await testUtils.dom.click(row);
+ var modal_row = $('.modal-body .o_form_sheet .o_field_one2many .o_list_view .o_data_row');
+ assert.strictEqual(modal_row.children().length, 2,
+ "the row should contains the 2 fields defined in the form view");
+ assert.strictEqual($(modal_row).text(), "gold2",
+ "the value of the fields should be fetched and displayed");
+ assert.verifySteps(['read', 'read', 'load_views', 'read', 'read'],
+ "there should be 4 read rpcs");
+ form.destroy();
+ });
+
+ QUnit.test('in edit mode, first field is focused', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ await testUtils.form.clickEdit(form);
+
+ assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
+ "foo field should have focus");
+ assert.strictEqual(form.$('input[name="foo"]')[0].selectionStart, 3,
+ "cursor should be at the end");
+
+ form.destroy();
+ });
+
+ QUnit.test('autofocus fields are focused', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="bar"/>' +
+ '<field name="foo" default_focus="1"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
+ "foo field should have focus");
+
+ form.destroy();
+ });
+
+ QUnit.test('correct amount of buttons', async function (assert) {
+ assert.expect(7);
+
+ var self = this;
+ var buttons = Array(8).join(
+ '<button type="object" class="oe_stat_button" icon="fa-check-square">' +
+ '<field name="bar"/>' +
+ '</button>'
+ );
+ var statButtonSelector = '.oe_stat_button:not(.dropdown-item, .dropdown-toggle)';
+
+ var createFormWithDeviceSizeClass = async function (size_class) {
+ return await createView({
+ View: FormView,
+ model: 'partner',
+ data: self.data,
+ arch: '<form>' +
+ '<div name="button_box" class="oe_button_box">'
+ + buttons +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ config: {
+ device: {size_class: size_class},
+ },
+ });
+ };
+
+ var assertFormContainsNButtonsWithSizeClass = async function (size_class, n) {
+ var form = await createFormWithDeviceSizeClass(size_class);
+ assert.containsN(form, statButtonSelector, n, 'The form has the expected amount of buttons');
+ form.destroy();
+ };
+
+ await assertFormContainsNButtonsWithSizeClass(0, 2);
+ await assertFormContainsNButtonsWithSizeClass(1, 2);
+ await assertFormContainsNButtonsWithSizeClass(2, 2);
+ await assertFormContainsNButtonsWithSizeClass(3, 4);
+ await assertFormContainsNButtonsWithSizeClass(4, 7);
+ await assertFormContainsNButtonsWithSizeClass(5, 7);
+ await assertFormContainsNButtonsWithSizeClass(6, 7);
+ });
+
+ QUnit.test('can set bin_size to false in context', async function (assert){
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ context: {
+ bin_size: false,
+ },
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.kwargs.context.bin_size, false,
+ "bin_size should always be in the context and should be false");
+ return this._super(route, args);
+ }
+ });
+ form.destroy();
+ });
+
+ QUnit.test('no focus set on form when closing many2one modal if lastActivatedFieldIndex is not set', async function (assert) {
+ assert.expect(8);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="p"/>' +
+ '<field name="timmy"/>' +
+ '<field name="product_ids"/>' +
+ '<field name="trululu"/>' +
+ '</form>',
+ res_id: 2,
+ archs: {
+ 'partner,false,list': '<tree><field name="display_name"/></tree>',
+ 'partner_type,false,list': '<tree><field name="name"/></tree>',
+ 'partner,false,form': '<form><field name="trululu"/></form>',
+ 'product,false,list': '<tree><field name="name"/></tree>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // set max-height to have scroll forcefully so that we can test scroll position after modal close
+ $('.o_content').css({'overflow': 'auto', 'max-height': '300px'});
+ // Open many2one modal, lastActivatedFieldIndex will not set as we directly click on external button
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual($(".o_content").scrollTop(), 0, "scroll position should be 0");
+
+ form.$(".o_field_many2one[name='trululu'] .o_input").focus();
+ assert.notStrictEqual($(".o_content").scrollTop(), 0, "scroll position should not be 0");
+
+ await testUtils.dom.click(form.$('.o_external_button'));
+ // Close modal
+ await testUtils.dom.click($('.modal').last().find('button[class="close"]'));
+ assert.notStrictEqual($(".o_content").scrollTop(), 0,
+ "scroll position should not be 0 after closing modal");
+ assert.containsNone(document.body, '.modal', 'There should be no modal');
+ assert.doesNotHaveClass($('body'), 'modal-open', 'Modal is not said opened');
+ assert.strictEqual(form.renderer.lastActivatedFieldIndex, -1,
+ "lastActivatedFieldIndex is -1");
+ assert.equal(document.activeElement, $('body')[0],
+ 'body is focused, should not set focus on form widget');
+ assert.notStrictEqual(document.activeElement, form.$('.o_field_many2one[name="trululu"] .o_input'),
+ 'field widget should not be focused when lastActivatedFieldIndex is -1');
+
+ form.destroy();
+ });
+
+ QUnit.test('in create mode, autofocus fields are focused', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="foo" default_focus="1"/>' +
+ '</form>',
+ });
+ assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
+ "foo field should have focus");
+
+ form.destroy();
+ });
+
+ QUnit.test('create with false values', async function (assert) {
+ assert.expect(1);
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="bar"/></group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.strictEqual(args.args[0].bar, false,
+ "the false value should be given as parameter");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ await testUtils.form.clickSave(form);
+
+ form.destroy();
+ });
+
+ QUnit.test('autofocus first visible field', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field" invisible="1"/>' +
+ '<field name="foo"/>' +
+ '</form>',
+ });
+ assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
+ "foo field should have focus");
+
+ form.destroy();
+ });
+
+ QUnit.test('no autofocus with disable_autofocus option [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="foo"/>' +
+ '</form>',
+ viewOptions: {
+ disable_autofocus: true,
+ },
+ });
+ assert.notStrictEqual(document.activeElement, form.$('input[name="int_field"]')[0],
+ "int_field field should not have focus");
+
+ await form.update({});
+
+ assert.notStrictEqual(document.activeElement, form.$('input[name="int_field"]')[0],
+ "int_field field should not have focus");
+
+ form.destroy();
+ });
+
+ QUnit.test('open one2many form containing many2many_tags', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].product_ids = [37];
+ this.data.product.fields.partner_type_ids = {
+ string: "many2many partner_type", type: "many2many", relation: "partner_type",
+ };
+ this.data.product.records[0].partner_type_ids = [12, 14];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ res_id: 1,
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="product_ids">' +
+ '<tree create="0">' +
+ '<field name="display_name"/>' +
+ '<field name="partner_type_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '<form string="Products">' +
+ '<sheet>' +
+ '<group>' +
+ '<label for="partner_type_ids"/>' +
+ '<div>' +
+ '<field name="partner_type_ids" widget="many2many_tags"/>' +
+ '</div>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+ var row = form.$('.o_field_one2many .o_list_view .o_data_row');
+ await testUtils.dom.click(row);
+ assert.verifySteps(['read', 'read', 'read'],
+ "there should be 3 read rpcs");
+ form.destroy();
+ });
+
+ QUnit.test('onchanges are applied before checking if it can be saved', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges.foo = function (obj) {};
+ this.data.partner.fields.foo.required = true;
+
+ var def = testUtils.makeTestPromise();
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet><group>' +
+ '<field name="foo"/>' +
+ '</group></sheet>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ assert.step(args.method);
+ if (args.method === 'onchange') {
+ return def.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function (params) {
+ assert.step(params.type);
+ }
+ }),
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), '');
+ await testUtils.form.clickSave(form);
+
+ def.resolve();
+ await testUtils.nextTick();
+
+ assert.verifySteps(['read', 'onchange', 'danger']);
+ form.destroy();
+ });
+
+ QUnit.test('display toolbar', async function (assert) {
+ assert.expect(8);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ res_id: 1,
+ arch: '<form string="Partners">' +
+ '<group><field name="bar"/></group>' +
+ '</form>',
+ toolbar: {
+ action: [{
+ model_name: 'partner',
+ name: 'Action partner',
+ type: 'ir.actions.server',
+ usage: 'ir_actions_server',
+ }],
+ print: [],
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/action/load') {
+ assert.strictEqual(args.context.active_id, 1,
+ "the active_id shoud be 1.");
+ assert.deepEqual(args.context.active_ids, [1],
+ "the active_ids should be an array with 1 inside.");
+ return Promise.resolve({});
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsNone(form, '.o_cp_action_menus .o_dropdown:contains(Print)');
+ assert.containsOnce(form, '.o_cp_action_menus .o_dropdown:contains(Action)');
+
+ await cpHelpers.toggleActionMenu(form);
+
+ assert.containsN(form, '.o_cp_action_menus .dropdown-item', 3, "there should be 3 actions");
+ assert.strictEqual(form.$('.o_cp_action_menus .dropdown-item:last').text().trim(), 'Action partner',
+ "the custom action should have 'Action partner' as name");
+
+ await testUtils.mock.intercept(form, 'do_action', function (event) {
+ var context = event.data.action.context.__contexts[1];
+ assert.strictEqual(context.active_id, 1,
+ "the active_id shoud be 1.");
+ assert.deepEqual(context.active_ids, [1],
+ "the active_ids should be an array with 1 inside.");
+ });
+ await cpHelpers.toggleMenuItem(form, "Action partner");
+
+ form.destroy();
+ });
+
+ QUnit.test('check interactions between multiple FormViewDialogs', async function (assert) {
+ assert.expect(8);
+
+ this.data.product.fields.product_ids = {
+ string: "one2many product", type: "one2many", relation: "product",
+ };
+
+ this.data.partner.records[0].product_id = 37;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ res_id: 1,
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="product_id"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'product,false,form':
+ '<form string="Products">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="display_name"/>' +
+ '<field name="product_ids"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ 'product,false,list': '<tree><field name="display_name"/></tree>'
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/product/get_formview_id') {
+ return Promise.resolve(false);
+ } else if (args.method === 'write') {
+ assert.strictEqual(args.model, 'product',
+ "should write on product model");
+ assert.strictEqual(args.args[1].product_ids[0][2].display_name, 'xtv',
+ "display_name of the new object should be xtv");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ // Open first dialog
+ await testUtils.dom.click(form.$('.o_external_button'));
+ assert.strictEqual($('.modal').length, 1,
+ "One FormViewDialog should be opened");
+ var $firstModal = $('.modal');
+ assert.strictEqual($('.modal .modal-title').first().text().trim(), 'Open: Product',
+ "dialog title should display the python field string as label");
+ assert.strictEqual($firstModal.find('input').val(), 'xphone',
+ "display_name should be correctly displayed");
+
+ // Open second dialog
+ await testUtils.dom.click($firstModal.find('.o_field_x2many_list_row_add a'));
+ assert.strictEqual($('.modal').length, 2,
+ "two FormViewDialogs should be opened");
+ var $secondModal = $('.modal:nth(1)');
+ // Add new value
+ await testUtils.fields.editInput($secondModal.find('input'), 'xtv');
+ await testUtils.dom.click($secondModal.find('.modal-footer button:first'));
+ assert.strictEqual($('.modal').length, 1,
+ "last opened dialog should be closed");
+
+ // Check that data in first dialog is correctly updated
+ assert.strictEqual($firstModal.find('tr.o_data_row td').text(), 'xtv',
+ "should have added a line with xtv as new record");
+ await testUtils.dom.click($firstModal.find('.modal-footer button:first'));
+ form.destroy();
+ });
+
+ QUnit.test('fields and record contexts are not mixed', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="trululu" context="{\'test\': 1}"/>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.strictEqual(args.kwargs.context.test, 1,
+ "field's context should be sent");
+ assert.notOk('mainContext' in args.kwargs.context,
+ "record's context should not be sent");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 2,
+ viewOptions: {
+ mode: 'edit',
+ context: {mainContext: 3},
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_field_widget[name=trululu] input'));
+
+ form.destroy();
+ });
+
+ QUnit.test('do not activate an hidden tab when switching between records', async function (assert) {
+ assert.expect(6);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="Foo" attrs=\'{"invisible": [["id", "=", 2]]}\'>' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '<page string="Bar">' +
+ '<field name="bar"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_notebook .nav-item:not(.o_invisible_modifier)').length, 2,
+ "both tabs should be visible");
+ assert.hasClass(form.$('.o_notebook .nav-link:first'),'active',
+ "first tab should be active");
+
+ // click on the pager to switch to the next record
+ await cpHelpers.pagerNext(form);
+
+ assert.strictEqual(form.$('.o_notebook .nav-item:not(.o_invisible_modifier)').length, 1,
+ "only the second tab should be visible");
+ assert.hasClass(form.$('.o_notebook .nav-item:not(.o_invisible_modifier) .nav-link'),'active',
+ "the visible tab should be active");
+
+ // click on the pager to switch back to the previous record
+ await cpHelpers.pagerPrevious(form);
+
+ assert.strictEqual(form.$('.o_notebook .nav-item:not(.o_invisible_modifier)').length, 2,
+ "both tabs should be visible again");
+ assert.hasClass(form.$('.o_notebook .nav-link:nth(1)'),'active',
+ "second tab should be active");
+
+ form.destroy();
+ });
+
+ QUnit.test('support anchor tags with action type', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<a type="action" name="42"><i class="fa fa-arrow-right"/> Click me !</a>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ do_action: function (event) {
+ assert.strictEqual(event.data.action, "42",
+ "should trigger do_action with correct action parameter");
+ }
+ }
+ });
+ await testUtils.dom.click(form.$('a[type="action"]'));
+
+ form.destroy();
+ });
+
+ QUnit.test('do not perform extra RPC to read invisible many2one fields', async function (assert) {
+ // This test isn't really meaningful anymore, since default_get and (first) onchange rpcs
+ // have been merged in a single onchange rpc, returning nameget for many2one fields. But it
+ // isn't really costly, and it still checks rpcs done when creating a new record with a m2o.
+ assert.expect(2);
+
+ this.data.partner.fields.trululu.default = 2;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="trululu" invisible="1"/>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['onchange'], "only one RPC should have been done");
+
+ form.destroy();
+ });
+
+ QUnit.test('do not perform extra RPC to read invisible x2many fields', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [2]; // one2many
+ this.data.partner.records[0].product_ids = [37]; // one2many
+ this.data.partner.records[0].timmy = [12]; // many2many
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p" invisible="1"/>' + // no inline view
+ '<field name="product_ids" invisible="1">' + // inline view
+ '<tree><field name="display_name"/></tree>' +
+ '</field>' +
+ '<field name="timmy" invisible="1" widget="many2many_tags"/>' + // no view
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+
+ assert.verifySteps(['read'], "only one read should have been done");
+
+ form.destroy();
+ });
+
+ QUnit.test('default_order on x2many embedded view', async function (assert) {
+ assert.expect(11);
+
+ this.data.partner.fields.display_name.sortable = true;
+ this.data.partner.records[0].p = [1, 4];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree default_order="foo desc">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ },
+ res_id: 1,
+ });
+
+ assert.ok(form.$('.o_field_one2many tbody tr:first td:contains(yop)').length,
+ "record 1 should be first");
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ assert.strictEqual($('.modal').length, 1,
+ "FormViewDialog should be opened");
+ await testUtils.fields.editInput($('.modal input[name="foo"]'), 'xop');
+ await testUtils.dom.click($('.modal-footer button:eq(1)'));
+ await testUtils.fields.editInput($('.modal input[name="foo"]'), 'zop');
+ await testUtils.dom.click($('.modal-footer button:first'));
+
+ // client-side sort
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zop)').length,
+ "record zop should be first");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').length,
+ "record yop should be second");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length,
+ "record xop should be third");
+
+ // server-side sort
+ await testUtils.form.clickSave(form);
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zop)').length,
+ "record zop should be first");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').length,
+ "record yop should be second");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length,
+ "record xop should be third");
+
+ // client-side sort on edit
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)'));
+ await testUtils.fields.editInput($('.modal input[name="foo"]'), 'zzz');
+ await testUtils.dom.click($('.modal-footer button:first'));
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zzz)').length,
+ "record zzz should be first");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(zop)').length,
+ "record zop should be second");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length,
+ "record xop should be third");
+
+ form.destroy();
+ });
+
+ QUnit.test('action context is used when evaluating domains', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="trululu" domain="[(\'id\', \'in\', context.get(\'product_ids\', []))]"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ context: {product_ids: [45,46,47]}
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.deepEqual(args.kwargs.args[0], ['id', 'in', [45,46,47]],
+ "domain should be properly evaluated");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('div[name="trululu"] input'));
+
+ form.destroy();
+ });
+
+ QUnit.test('form rendering with groups with col/colspan', async function (assert) {
+ assert.expect(45);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form>' +
+ '<sheet>' +
+ '<group col="6" class="parent_group">' +
+ '<group col="4" colspan="3" class="group_4">' +
+ '<div colspan="3"/>' +
+ '<div colspan="2"/><div/>' +
+ '<div colspan="4"/>' +
+ '</group>' +
+ '<group col="3" colspan="4" class="group_3">' +
+ '<group col="1" class="group_1">' +
+ '<div/><div/><div/>' +
+ '</group>' +
+ '<div/>' +
+ '<group col="3" class="field_group">' +
+ '<field name="foo" colspan="3"/>' +
+ '<div/><field name="bar" nolabel="1"/>' +
+ '<field name="qux"/>' +
+ '<field name="int_field" colspan="3" nolabel="1"/>' +
+ '<span/><field name="product_id"/>' +
+ '</group>' +
+ '</group>' +
+ '</group>' +
+ '<group>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ var $parentGroup = form.$('.parent_group');
+ var $group4 = form.$('.group_4');
+ var $group3 = form.$('.group_3');
+ var $group1 = form.$('.group_1');
+ var $fieldGroup = form.$('.field_group');
+
+ // Verify outergroup/innergroup
+ assert.strictEqual($parentGroup[0].tagName, 'DIV', ".parent_group should be an outergroup");
+ assert.strictEqual($group4[0].tagName, 'TABLE', ".group_4 should be an innergroup");
+ assert.strictEqual($group3[0].tagName, 'DIV', ".group_3 should be an outergroup");
+ assert.strictEqual($group1[0].tagName, 'TABLE', ".group_1 should be an innergroup");
+ assert.strictEqual($fieldGroup[0].tagName, 'TABLE', ".field_group should be an innergroup");
+
+ // Verify .parent_group content
+ var $parentGroupChildren = $parentGroup.children();
+ assert.strictEqual($parentGroupChildren.length, 2, "there should be 2 groups in .parent_group");
+ assert.ok($parentGroupChildren.eq(0).is('.o_group_col_6'), "first .parent_group group should be 1/2 parent width");
+ assert.ok($parentGroupChildren.eq(1).is('.o_group_col_8'), "second .parent_group group should be 2/3 parent width");
+
+ // Verify .group_4 content
+ var $group4rows = $group4.find('> tbody > tr');
+ assert.strictEqual($group4rows.length, 3, "there should be 3 rows in .group_4");
+ var $group4firstRowTd = $group4rows.eq(0).children('td');
+ assert.strictEqual($group4firstRowTd.length, 1, "there should be 1 td in first row");
+ assert.hasAttrValue($group4firstRowTd, 'colspan', "3", "the first td colspan should be 3");
+ assert.strictEqual($group4firstRowTd.attr('style').substr(0, 9), "width: 75", "the first td should be 75% width");
+ assert.strictEqual($group4firstRowTd.children()[0].tagName, "DIV", "the first td should contain a div");
+ var $group4secondRowTds = $group4rows.eq(1).children('td');
+ assert.strictEqual($group4secondRowTds.length, 2, "there should be 2 tds in second row");
+ assert.hasAttrValue($group4secondRowTds.eq(0), 'colspan', "2", "the first td colspan should be 2");
+ assert.strictEqual($group4secondRowTds.eq(0).attr('style').substr(0, 9), "width: 50", "the first td be 50% width");
+ assert.hasAttrValue($group4secondRowTds.eq(1), 'colspan', undefined, "the second td colspan should be default one (1)");
+ assert.strictEqual($group4secondRowTds.eq(1).attr('style').substr(0, 9), "width: 25", "the second td be 75% width");
+ var $group4thirdRowTd = $group4rows.eq(2).children('td');
+ assert.strictEqual($group4thirdRowTd.length, 1, "there should be 1 td in third row");
+ assert.hasAttrValue($group4thirdRowTd, 'colspan', "4", "the first td colspan should be 4");
+ assert.strictEqual($group4thirdRowTd.attr('style').substr(0, 10), "width: 100", "the first td should be 100% width");
+
+ // Verify .group_3 content
+ assert.strictEqual($group3.children().length, 3, ".group_3 should have 3 children");
+ assert.strictEqual($group3.children('.o_group_col_4').length, 3, ".group_3 should have 3 children of 1/3 width");
+
+ // Verify .group_1 content
+ assert.strictEqual($group1.find('> tbody > tr').length, 3, "there should be 3 rows in .group_1");
+
+ // Verify .field_group content
+ var $fieldGroupRows = $fieldGroup.find('> tbody > tr');
+ assert.strictEqual($fieldGroupRows.length, 5, "there should be 5 rows in .field_group");
+ var $fieldGroupFirstRowTds = $fieldGroupRows.eq(0).children('td');
+ assert.strictEqual($fieldGroupFirstRowTds.length, 2, "there should be 2 tds in first row");
+ assert.hasClass($fieldGroupFirstRowTds.eq(0),'o_td_label', "first td should be a label td");
+ assert.hasAttrValue($fieldGroupFirstRowTds.eq(1), 'colspan', "2", "second td colspan should be given colspan (3) - 1 (label)");
+ assert.strictEqual($fieldGroupFirstRowTds.eq(1).attr('style').substr(0, 10), "width: 100", "second td width should be 100%");
+ var $fieldGroupSecondRowTds = $fieldGroupRows.eq(1).children('td');
+ assert.strictEqual($fieldGroupSecondRowTds.length, 2, "there should be 2 tds in second row");
+ assert.hasAttrValue($fieldGroupSecondRowTds.eq(0), 'colspan', undefined, "first td colspan should be default one (1)");
+ assert.strictEqual($fieldGroupSecondRowTds.eq(0).attr('style').substr(0, 9), "width: 33", "first td width should be 33.3333%");
+ assert.hasAttrValue($fieldGroupSecondRowTds.eq(1), 'colspan', undefined, "second td colspan should be default one (1)");
+ assert.strictEqual($fieldGroupSecondRowTds.eq(1).attr('style').substr(0, 9), "width: 33", "second td width should be 33.3333%");
+ var $fieldGroupThirdRowTds = $fieldGroupRows.eq(2).children('td'); // new row as label/field pair colspan is greater than remaining space
+ assert.strictEqual($fieldGroupThirdRowTds.length, 2, "there should be 2 tds in third row");
+ assert.hasClass($fieldGroupThirdRowTds.eq(0),'o_td_label', "first td should be a label td");
+ assert.hasAttrValue($fieldGroupThirdRowTds.eq(1), 'colspan', undefined, "second td colspan should be default one (1)");
+ assert.strictEqual($fieldGroupThirdRowTds.eq(1).attr('style').substr(0, 9), "width: 50", "second td should be 50% width");
+ var $fieldGroupFourthRowTds = $fieldGroupRows.eq(3).children('td');
+ assert.strictEqual($fieldGroupFourthRowTds.length, 1, "there should be 1 td in fourth row");
+ assert.hasAttrValue($fieldGroupFourthRowTds, 'colspan', "3", "the td should have a colspan equal to 3");
+ assert.strictEqual($fieldGroupFourthRowTds.attr('style').substr(0, 10), "width: 100", "the td should have 100% width");
+ var $fieldGroupFifthRowTds = $fieldGroupRows.eq(4).children('td'); // label/field pair can be put after the 1-colspan span
+ assert.strictEqual($fieldGroupFifthRowTds.length, 3, "there should be 3 tds in fourth row");
+ assert.strictEqual($fieldGroupFifthRowTds.eq(0).attr('style').substr(0, 9), "width: 50", "the first td should 50% width");
+ assert.hasClass($fieldGroupFifthRowTds.eq(1),'o_td_label', "the second td should be a label td");
+ assert.strictEqual($fieldGroupFifthRowTds.eq(2).attr('style').substr(0, 9), "width: 50", "the third td should 50% width");
+
+ form.destroy();
+ });
+
+ QUnit.test('outer and inner groups string attribute', async function (assert) {
+ assert.expect(5);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group string="parent group" class="parent_group">' +
+ '<group string="child group 1" class="group_1">' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '<group string="child group 2" class="group_2">' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ var $parentGroup = form.$('.parent_group');
+ var $group1 = form.$('.group_1');
+ var $group2 = form.$('.group_2');
+
+ assert.containsN(form, 'table.o_inner_group', 2,
+ "should contain two inner groups");
+ assert.strictEqual($group1.find('.o_horizontal_separator').length, 1,
+ "inner group should contain one string separator");
+ assert.strictEqual($group1.find('.o_horizontal_separator:contains(child group 1)').length, 1,
+ "first inner group should contain 'child group 1' string");
+ assert.strictEqual($group2.find('.o_horizontal_separator:contains(child group 2)').length, 1,
+ "second inner group should contain 'child group 2' string");
+ assert.strictEqual($parentGroup.find('> div.o_horizontal_separator:contains(parent group)').length, 1,
+ "outer group should contain 'parent group' string");
+
+ form.destroy();
+ });
+
+ QUnit.test('form group with newline tag inside', async function (assert) {
+ assert.expect(6);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form>' +
+ '<sheet>' +
+ '<group col="5" class="main_inner_group">' +
+ // col=5 otherwise the test is ok even without the
+ // newline code as this will render a <newline/> DOM
+ // element in the third column, leaving no place for
+ // the next field and its label on the same line.
+ '<field name="foo"/>' +
+ '<newline/>' +
+ '<field name="bar"/>' +
+ '<field name="qux"/>' +
+ '</group>' +
+ '<group col="3">' +
+ // col=3 otherwise the test is ok even without the
+ // newline code as this will render a <newline/> DOM
+ // element with the o_group_col_6 class, leaving no
+ // place for the next group on the same line.
+ '<group class="top_group">' +
+ '<div style="height: 200px;"/>' +
+ '</group>' +
+ '<newline/>' +
+ '<group class="bottom_group">' +
+ '<div/>' +
+ '</group>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // Inner group
+ assert.containsN(form, '.main_inner_group > tbody > tr', 2,
+ "there should be 2 rows in the group");
+ assert.containsOnce(form, '.main_inner_group > tbody > tr:first > .o_td_label',
+ "there should be only one label in the first row");
+ assert.containsOnce(form, '.main_inner_group > tbody > tr:first .o_field_widget',
+ "there should be only one widget in the first row");
+ assert.containsN(form, '.main_inner_group > tbody > tr:last > .o_td_label', 2,
+ "there should be two labels in the second row");
+ assert.containsN(form, '.main_inner_group > tbody > tr:last .o_field_widget', 2,
+ "there should be two widgets in the second row");
+
+ // Outer group
+ assert.ok((form.$('.bottom_group').position().top - form.$('.top_group').position().top) >= 200,
+ "outergroup children should not be on the same line");
+
+ form.destroy();
+ });
+
+ QUnit.test('custom open record dialog title', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].p = [2];
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="p" widget="many2many" string="custom label">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ session: {},
+ res_id: 1,
+ });
+
+ await testUtils.dom.click(form.$('.o_data_row:first'));
+ assert.strictEqual($('.modal .modal-title').first().text().trim(), 'Open: custom label',
+ "modal should use the python field string as title");
+
+ form.destroy();
+ });
+
+ QUnit.test('display translation alert', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.foo.translate = true;
+ this.data.partner.fields.display_name.translate = true;
+
+ var multi_lang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="display_name"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), "test");
+ await testUtils.form.clickSave(form);
+ assert.containsOnce(form, '.o_form_view .alert > div .oe_field_translate',
+ "should have single translation alert");
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name="display_name"]'), "test2");
+ await testUtils.form.clickSave(form);
+ assert.containsN(form, '.o_form_view .alert > div .oe_field_translate', 2,
+ "should have two translate fields in translation alert");
+
+ form.destroy();
+
+ _t.database.multi_lang = multi_lang;
+ });
+
+ QUnit.test('translation alerts are preserved on pager change', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.foo.translate = true;
+
+ var multi_lang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '</form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), "test");
+ await testUtils.form.clickSave(form);
+
+ assert.containsOnce(form, '.o_form_view .alert > div', "should have a translation alert");
+
+ // click on the pager to switch to the next record
+ await cpHelpers.pagerNext(form);
+ assert.containsNone(form, '.o_form_view .alert > div', "should not have a translation alert");
+
+ // click on the pager to switch back to the previous record
+ await cpHelpers.pagerPrevious(form);
+ assert.containsOnce(form, '.o_form_view .alert > div', "should have a translation alert");
+
+ // remove translation alert by click X and check alert even after form reload
+ await testUtils.dom.click(form.$('.o_form_view .alert > .close'));
+ assert.containsNone(form, '.o_form_view .alert > div', "should not have a translation alert");
+
+ await form.reload();
+ assert.containsNone(form, '.o_form_view .alert > div', "should not have a translation alert after reload");
+
+ form.destroy();
+ _t.database.multi_lang = multi_lang;
+ });
+
+ QUnit.test('translation alerts preseved on reverse breadcrumb', async function (assert) {
+ assert.expect(2);
+
+ this.data['ir.translation'] = {
+ fields: {
+ name: { string: "name", type: "char" },
+ source: {string: "Source", type: "char"},
+ value: {string: "Value", type: "char"},
+ },
+ records: [],
+ };
+
+ this.data.partner.fields.foo.translate = true;
+
+ var multi_lang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ var archs = {
+ 'partner,false,form': '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '</form>',
+ 'partner,false,search': '<search></search>',
+ 'ir.translation,false,list': '<tree>' +
+ '<field name="name"/>' +
+ '<field name="source"/>' +
+ '<field name="value"/>' +
+ '</tree>',
+ 'ir.translation,false,search': '<search></search>',
+ };
+
+ var actions = [{
+ id: 1,
+ name: 'Partner',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'form']],
+ }, {
+ id: 2,
+ name: 'Translate',
+ res_model: 'ir.translation',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list']],
+ target: 'current',
+ flags: {'search_view': true, 'action_buttons': true},
+ }];
+
+ var actionManager = await createActionManager({
+ actions: actions,
+ archs: archs,
+ data: this.data,
+ });
+
+ await actionManager.doAction(1);
+ actionManager.$('input[name="foo"]').val("test").trigger("input");
+ await testUtils.dom.click(actionManager.$('.o_form_button_save'));
+
+ assert.strictEqual(actionManager.$('.o_form_view .alert > div').length, 1,
+ "should have a translation alert");
+
+ var currentController = actionManager.getCurrentController().widget;
+ await actionManager.doAction(2, {
+ on_reverse_breadcrumb: function () {
+ if (!_.isEmpty(currentController.renderer.alertFields)) {
+ currentController.renderer.displayTranslationAlert();
+ }
+ return false;
+ },
+ });
+
+ await testUtils.dom.click($('.o_control_panel .breadcrumb a:first'));
+ assert.strictEqual(actionManager.$('.o_form_view .alert > div').length, 1,
+ "should have a translation alert");
+
+ actionManager.destroy();
+ _t.database.multi_lang = multi_lang;
+ });
+
+ QUnit.test('translate event correctly handled with multiple controllers', async function (assert) {
+ assert.expect(3);
+
+ this.data.product.fields.name.translate = true;
+ this.data.partner.records[0].product_id = 37;
+ var nbTranslateCalls = 0;
+
+ var multi_lang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="product_id"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'product,false,form': '<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="name"/>' +
+ '<field name="partner_type_id"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/product/get_formview_id') {
+ return Promise.resolve(false);
+ }
+ if (route === "/web/dataset/call_button" && args.method === 'translate_fields') {
+ assert.deepEqual(args.args, ["product",37,"name"], 'should call "call_button" route');
+ nbTranslateCalls++;
+ return Promise.resolve({
+ domain: [],
+ context: {search_default_name: 'partnes,foo'},
+ });
+ }
+ if (route === "/web/dataset/call_kw/res.lang/get_installed") {
+ return Promise.resolve([["en_US"], ["fr_BE"]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('[name="product_id"] .o_external_button'));
+ assert.containsOnce($('.modal-body'), 'span.o_field_translate',
+ "there should be a translate button in the modal");
+
+ await testUtils.dom.click($('.modal-body span.o_field_translate'));
+ assert.strictEqual(nbTranslateCalls, 1, "should call_button translate once");
+
+ form.destroy();
+ _t.database.multi_lang = multi_lang;
+ });
+
+ QUnit.test('buttons are disabled until status bar action is resolved', async function (assert) {
+ assert.expect(9);
+
+ var def = testUtils.makeTestPromise();
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="post" class="p" string="Confirm" type="object"/>' +
+ '<button name="some_method" class="s" string="Do it" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<div name="button_box" class="oe_button_box">' +
+ '<button class="oe_stat_button">' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '</div>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ execute_action: function (event) {
+ return def.then(function() {
+ event.data.on_success();
+ });
+ }
+ },
+ });
+
+ assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4,
+ "control panel buttons should be enabled");
+ assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2,
+ "status bar buttons should be enabled");
+ assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1,
+ "stat buttons should be enabled");
+
+ await testUtils.dom.clickFirst(form.$('.o_form_statusbar button'));
+
+ // The unresolved promise lets us check the state of the buttons
+ assert.strictEqual(form.$buttons.find('button:disabled').length, 4,
+ "control panel buttons should be disabled");
+ assert.containsN(form, '.o_form_statusbar button:disabled', 2,
+ "status bar buttons should be disabled");
+ assert.containsOnce(form, '.oe_button_box button:disabled',
+ "stat buttons should be disabled");
+
+ def.resolve();
+ await testUtils.nextTick();
+ assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4,
+ "control panel buttons should be enabled");
+ assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2,
+ "status bar buttons should be enabled");
+ assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1,
+ "stat buttons should be enabled");
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons are disabled until button box action is resolved', async function (assert) {
+ assert.expect(9);
+
+ var def = testUtils.makeTestPromise();
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="post" class="p" string="Confirm" type="object"/>' +
+ '<button name="some_method" class="s" string="Do it" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<div name="button_box" class="oe_button_box">' +
+ '<button class="oe_stat_button">' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '</div>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ execute_action: function (event) {
+ return def.then(function() {
+ event.data.on_success();
+ });
+ }
+ },
+ });
+
+ assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4,
+ "control panel buttons should be enabled");
+ assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2,
+ "status bar buttons should be enabled");
+ assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1,
+ "stat buttons should be enabled");
+
+ await testUtils.dom.click(form.$('.oe_button_box button'));
+
+ // The unresolved promise lets us check the state of the buttons
+ assert.strictEqual(form.$buttons.find('button:disabled').length, 4,
+ "control panel buttons should be disabled");
+ assert.containsN(form, '.o_form_statusbar button:disabled', 2,
+ "status bar buttons should be disabled");
+ assert.containsOnce(form, '.oe_button_box button:disabled',
+ "stat buttons should be disabled");
+
+ def.resolve();
+ await testUtils.nextTick();
+ assert.strictEqual(form.$buttons.find('button:not(:disabled)').length, 4,
+ "control panel buttons should be enabled");
+ assert.strictEqual(form.$('.o_form_statusbar button:not(:disabled)').length, 2,
+ "status bar buttons should be enabled");
+ assert.strictEqual(form.$('.oe_button_box button:not(:disabled)').length, 1,
+ "stat buttons should be enabled");
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons with "confirm" attribute save before calling the method', async function (assert) {
+ assert.expect(9);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="post" class="p" string="Confirm" type="object" ' +
+ 'confirm="Very dangerous. U sure?"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ execute_action: function (event) {
+ assert.step('execute_action');
+ event.data.on_success();
+ },
+ },
+ });
+
+ // click on button, and cancel in confirm dialog
+ await testUtils.dom.click(form.$('.o_statusbar_buttons button'));
+ assert.ok(form.$('.o_statusbar_buttons button').prop('disabled'),
+ 'button should be disabled');
+ await testUtils.dom.click($('.modal-footer button.btn-secondary'));
+ assert.ok(!form.$('.o_statusbar_buttons button').prop('disabled'),
+ 'button should no longer be disabled');
+
+ assert.verifySteps(['onchange']);
+
+ // click on button, and click on ok in confirm dialog
+ await testUtils.dom.click(form.$('.o_statusbar_buttons button'));
+ assert.verifySteps([]);
+ await testUtils.dom.click($('.modal-footer button.btn-primary'));
+ assert.verifySteps(['create', 'read', 'execute_action']);
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons are disabled until action is resolved (in dialogs)', async function (assert) {
+ assert.expect(3);
+
+ var def = testUtils.makeTestPromise();
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="trululu"/>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<sheet>' +
+ '<div name="button_box" class="oe_button_box">' +
+ '<button class="oe_stat_button">' +
+ '<field name="bar"/>' +
+ '</button>' +
+ '</div>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ },
+ res_id: 1,
+ intercepts: {
+ execute_action: function (event) {
+ return def.then(function() {
+ event.data.on_success();
+ });
+ }
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_external_button'));
+
+ assert.notOk($('.modal .oe_button_box button').attr('disabled'),
+ "stat buttons should be enabled");
+
+ await testUtils.dom.click($('.modal .oe_button_box button'));
+
+ assert.ok($('.modal .oe_button_box button').attr('disabled'),
+ "stat buttons should be disabled");
+
+ def.resolve();
+ await testUtils.nextTick();
+ assert.notOk($('.modal .oe_button_box button').attr('disabled'),
+ "stat buttons should be enabled");
+
+ form.destroy();
+ });
+
+ QUnit.test('multiple clicks on save should reload only once', async function (assert) {
+ assert.expect(4);
+
+ var def = testUtils.makeTestPromise();
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ assert.step(args.method);
+ if (args.method === "write") {
+ return def.then(function () {
+ return result;
+ });
+ } else {
+ return result;
+ }
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), "test");
+ await testUtils.form.clickSave(form);
+ await testUtils.form.clickSave(form);
+
+ def.resolve();
+ await testUtils.nextTick();
+ assert.verifySteps([
+ 'read', // initial read to render the view
+ 'write', // write on save
+ 'read' // read on reload
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('form view is not broken if save operation fails', async function (assert) {
+ assert.expect(5);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write' && args.args[1].foo === 'incorrect value') {
+ return Promise.reject();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), "incorrect value");
+ await testUtils.form.clickSave(form);
+
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), "correct value");
+
+ await testUtils.form.clickSave(form);
+
+ assert.verifySteps([
+ 'read', // initial read to render the view
+ 'write', // write on save (it fails, does not trigger a read)
+ 'write', // write on save (it works)
+ 'read' // read on reload
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('form view is not broken if save failed in readonly mode on field changed', async function (assert) {
+ assert.expect(10);
+
+ var failFlag = false;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<header><field name="trululu" widget="statusbar" clickable="true"/></header>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.step('write');
+ if (failFlag) {
+ return Promise.reject();
+ }
+ } else if (args.method === 'read') {
+ assert.step('read');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var $selectedState = form.$('.o_statusbar_status button[data-value="4"]');
+ assert.ok($selectedState.hasClass('btn-primary') && $selectedState.hasClass('disabled'),
+ "selected status should be btn-primary and disabled");
+
+ failFlag = true;
+ var $clickableState = form.$('.o_statusbar_status button[data-value="1"]');
+ await testUtils.dom.click($clickableState);
+
+ var $lastActiveState = form.$('.o_statusbar_status button[data-value="4"]');
+ $selectedState = form.$('.o_statusbar_status button.btn-primary');
+ assert.strictEqual($selectedState[0], $lastActiveState[0],
+ "selected status is AAA record after save fail");
+
+ failFlag = false;
+ $clickableState = form.$('.o_statusbar_status button[data-value="1"]');
+ await testUtils.dom.click($clickableState);
+
+ var $lastClickedState = form.$('.o_statusbar_status button[data-value="1"]');
+ $selectedState = form.$('.o_statusbar_status button.btn-primary');
+ assert.strictEqual($selectedState[0], $lastClickedState[0],
+ "last clicked status should be active");
+
+ assert.verifySteps([
+ 'read',
+ 'write', // fails
+ 'read', // must reload when saving fails
+ 'write', // works
+ 'read', // must reload when saving works
+ 'read', // fixme: this read should not be necessary
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('support password attribute', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" password="True"/>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('span[name="foo"]').text(), '***',
+ "password should be displayed with stars");
+ await testUtils.form.clickEdit(form);
+ assert.strictEqual(form.$('input[name="foo"]').val(), 'yop',
+ "input value should be the password");
+ assert.strictEqual(form.$('input[name="foo"]').prop('type'), 'password',
+ "input should be of type password");
+ form.destroy();
+ });
+
+ QUnit.test('support autocomplete attribute', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name" autocomplete="coucou"/>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.hasAttrValue(form.$('input[name="display_name"]'), 'autocomplete', 'coucou',
+ "attribute autocomplete should be set");
+ form.destroy();
+ });
+
+ QUnit.test('input autocomplete attribute set to none by default', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.hasAttrValue(form.$('input[name="display_name"]'), 'autocomplete', 'off',
+ "attribute autocomplete should be set to none by default");
+ form.destroy();
+ });
+
+ QUnit.test('context is correctly passed after save & new in FormViewDialog', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ res_id: 4,
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="product_ids"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'product,false,form':
+ '<form string="Products">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="partner_type_id" ' +
+ 'context="{\'color\': parent.id}"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ 'product,false,list': '<tree><field name="display_name"/></tree>'
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.strictEqual(args.kwargs.context.color, 4,
+ "should use the correct context");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.nextTick();
+ assert.strictEqual($('.modal').length, 1,
+ "One FormViewDialog should be opened");
+ // set a value on the m2o
+ await testUtils.fields.many2one.clickOpenDropdown('partner_type_id');
+ await testUtils.fields.many2one.clickHighlightedItem('partner_type_id');
+
+ await testUtils.dom.click($('.modal-footer button:eq(1)'));
+ await testUtils.nextTick();
+ await testUtils.dom.click($('.modal .o_field_many2one input'));
+ await testUtils.fields.many2one.clickHighlightedItem('partner_type_id');
+ await testUtils.dom.click($('.modal-footer button:first'));
+ await testUtils.nextTick();
+ form.destroy();
+ });
+
+ QUnit.test('render domain field widget without model', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.model_name = { string: "Model name", type: "char" };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="model_name"/>' +
+ '<field name="display_name" widget="domain" options="{\'model\': \'model_name\'}"/>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'search_count') {
+ assert.strictEqual(args.model, 'test',
+ "should search_count on test");
+ if (!args.kwargs.domain) {
+ return Promise.reject({message:{
+ code: 200,
+ data: {},
+ message: "MockServer._getRecords: given domain has to be an array.",
+ }, event: $.Event()});
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_widget[name="display_name"]').text(), "Select a model to add a filter.",
+ "should contain an error message saying the model is missing");
+ await testUtils.fields.editInput(form.$('input[name="model_name"]'), "test");
+ assert.notStrictEqual(form.$('.o_field_widget[name="display_name"]').text(), "Select a model to add a filter.",
+ "should not contain an error message anymore");
+ form.destroy();
+ });
+
+ QUnit.test('readonly fields are not sent when saving', async function (assert) {
+ assert.expect(6);
+
+ // define an onchange on display_name to check that the value of readonly
+ // fields is correctly sent for onchanges
+ this.data.partner.onchanges = {
+ display_name: function () {},
+ p: function () {},
+ };
+ var checkOnchange = false;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'display_name\', \'=\', \'readonly\']]}"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (checkOnchange && args.method === 'onchange') {
+ if (args.args[2] === 'display_name') { // onchange on field display_name
+ assert.strictEqual(args.args[1].foo, 'foo value',
+ "readonly fields value should be sent for onchanges");
+ } else { // onchange on field p
+ assert.deepEqual(args.args[1].p, [
+ [0, args.args[1].p[0][1], {display_name: 'readonly', foo: 'foo value'}]
+ ], "readonly fields value should be sent for onchanges");
+ }
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {
+ p: [[0, args.args[0].p[0][1], {display_name: 'readonly'}]]
+ }, "should not have sent the value of the readonly field");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.nextTick();
+ assert.strictEqual($('.modal input.o_field_widget[name=foo]').length, 1,
+ 'foo should be editable');
+ checkOnchange = true;
+ await testUtils.fields.editInput($('.modal .o_field_widget[name=foo]'), 'foo value');
+ await testUtils.fields.editInput($('.modal .o_field_widget[name=display_name]'), 'readonly');
+ assert.strictEqual($('.modal span.o_field_widget[name=foo]').length, 1,
+ 'foo should be readonly');
+ await testUtils.dom.clickFirst($('.modal-footer .btn-primary'));
+ await testUtils.nextTick();
+ checkOnchange = false;
+
+ await testUtils.dom.click(form.$('.o_data_row'));
+ assert.strictEqual($('.modal .o_field_widget[name=foo]').text(), 'foo value',
+ "the edited value should have been kept");
+ await testUtils.dom.clickFirst($('.modal-footer .btn-primary'));
+ await testUtils.nextTick();
+
+ await testUtils.form.clickSave(form); // save the record
+ form.destroy();
+ });
+
+ QUnit.test('id is False in evalContext for new records', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="id"/>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'id\', \'=\', False]]}"/>' +
+ '</form>',
+ });
+
+ assert.hasClass(form.$('.o_field_widget[name=foo]'),'o_readonly_modifier',
+ "foo should be readonly in 'Create' mode");
+
+ await testUtils.form.clickSave(form);
+ await testUtils.form.clickEdit(form);
+
+ assert.doesNotHaveClass(form.$('.o_field_widget[name=foo]'), 'o_readonly_modifier',
+ "foo should not be readonly anymore");
+
+ form.destroy();
+ });
+
+ QUnit.test('delete a duplicated record', async function (assert) {
+ assert.expect(5);
+
+ var newRecordID;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {hasActionMenus: true},
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'copy') {
+ return result.then(function (id) {
+ newRecordID = id;
+ return id;
+ });
+ }
+ if (args.method === 'unlink') {
+ assert.deepEqual(args.args[0], [newRecordID],
+ "should delete the newly created record");
+ }
+ return result;
+ },
+ });
+
+ // duplicate record 1
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Duplicate");
+
+ assert.containsOnce(form, '.o_form_editable',
+ "form should be in edit mode");
+ assert.strictEqual(form.$('.o_field_widget').val(), 'first record (copy)',
+ "duplicated record should have correct name");
+ await testUtils.form.clickSave(form); // save duplicated record
+
+ // delete duplicated record
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Delete");
+
+ assert.strictEqual($('.modal').length, 1, "should have opened a confirm dialog");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.strictEqual(form.$('.o_field_widget').text(), 'first record',
+ "should have come back to previous record");
+
+ form.destroy();
+ });
+
+ QUnit.test('display tooltips for buttons', async function (assert) {
+ assert.expect(2);
+
+ var initialDebugMode = odoo.debug;
+ odoo.debug = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="some_method" class="oe_highlight" string="Button" type="object"/>' +
+ '</header>' +
+ '<button name="other_method" class="oe_highlight" string="Button2" type="object"/>' +
+ '</form>',
+ });
+
+ var $button = form.$('.o_form_statusbar button');
+ $button.tooltip('show', false);
+ $button.trigger($.Event('mouseenter'));
+
+ assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1,
+ "should have rendered a tooltip");
+ $button.trigger($.Event('mouseleave'));
+
+ var $secondButton = form.$('button[name="other_method"]');
+ $secondButton.tooltip('show', false);
+ $secondButton.trigger($.Event('mouseenter'));
+
+ assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1,
+ "should have rendered a tooltip");
+ $secondButton.trigger($.Event('mouseleave'));
+
+ odoo.debug = initialDebugMode;
+ form.destroy();
+ });
+
+ QUnit.test('reload event is handled only once', async function (assert) {
+ // In this test, several form controllers are nested (two of them are
+ // opened in dialogs). When the users clicks on save in the last
+ // opened dialog, a 'reload' event is triggered up to reload the (direct)
+ // parent view. If this event isn't stopPropagated by the first controller
+ // catching it, it will crash when the other one will try to handle it,
+ // as this one doesn't know at all the dataPointID to reload.
+ assert.expect(11);
+
+ var arch = '<form>' +
+ '<field name="display_name"/>' +
+ '<field name="trululu"/>' +
+ '</form>';
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: arch,
+ archs: {
+ 'partner,false,form': arch,
+ },
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_external_button'));
+ await testUtils.dom.click($('.modal .o_external_button'));
+
+ await testUtils.fields.editInput($('.modal:nth(1) .o_field_widget[name=display_name]'), 'new name');
+ await testUtils.dom.click($('.modal:nth(1) footer .btn-primary').first());
+
+ assert.strictEqual($('.modal .o_field_widget[name=trululu] input').val(), 'new name',
+ "record should have been reloaded");
+ assert.verifySteps([
+ "read", // main record
+ "get_formview_id", // id of first form view opened in a dialog
+ "load_views", // arch of first form view opened in a dialog
+ "read", // first dialog
+ "get_formview_id", // id of second form view opened in a dialog
+ "load_views", // arch of second form view opened in a dialog
+ "read", // second dialog
+ "write", // save second dialog
+ "read", // reload first dialog
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('process the context for inline subview', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].p = [2];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" invisible="context.get(\'hide_bar\', False)"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ context: {hide_bar: true},
+ },
+ });
+ assert.containsOnce(form, '.o_list_view thead tr th',
+ "there should be only one column");
+ form.destroy();
+ });
+
+ QUnit.test('process the context for subview not inline', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].p = [2];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p"/>' +
+ '</form>',
+ archs: {
+ "partner,false,list": '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" invisible="context.get(\'hide_bar\', False)"/>' +
+ '</tree>',
+ },
+ res_id: 1,
+ viewOptions: {
+ context: {hide_bar: true},
+ },
+ });
+ assert.containsOnce(form, '.o_list_view thead tr th',
+ "there should be only one column");
+ form.destroy();
+ });
+
+ QUnit.test('can toggle column in x2many in sub form view', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[2].p = [1,2];
+ this.data.partner.fields.foo.sortable = true;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="trululu"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ archs: {
+ 'partner,false,form': '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ },
+ viewOptions: {mode: 'edit'},
+ });
+ await testUtils.dom.click(form.$('.o_external_button'));
+ assert.strictEqual($('.modal-body .o_form_view .o_list_view .o_data_cell').text(), "yopblip",
+ "table has some initial order");
+
+ await testUtils.dom.click($('.modal-body .o_form_view .o_list_view th.o_column_sortable'));
+ assert.strictEqual($('.modal-body .o_form_view .o_list_view .o_data_cell').text(), "blipyop",
+ "table is now sorted");
+ form.destroy();
+ });
+
+ QUnit.test('rainbowman attributes correctly passed on button click', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="action_won" string="Won" type="object" effect="{\'message\': \'Congrats!\'}"/>' +
+ '</header>' +
+ '</form>',
+ intercepts: {
+ execute_action: function (event) {
+ var effectDescription = pyUtils.py_eval(event.data.action_data.effect);
+ assert.deepEqual(effectDescription, {message: 'Congrats!'}, "should have correct effect description");
+ }
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_form_statusbar .btn-secondary'));
+ form.destroy();
+ });
+
+ QUnit.test('basic support for widgets', async function (assert) {
+ assert.expect(1);
+
+ var MyWidget = Widget.extend({
+ init: function (parent, dataPoint) {
+ this.data = dataPoint.data;
+ },
+ start: function () {
+ this.$el.text(JSON.stringify(this.data));
+ },
+ });
+ widgetRegistry.add('test', MyWidget);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<widget name="test"/>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('.o_widget').text(), '{"foo":"My little Foo Value","bar":false}',
+ "widget should have been instantiated");
+
+ form.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('attach document widget calls action with attachment ids', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'my_action') {
+ assert.deepEqual(args.kwargs.attachment_ids, [5, 2]);
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: '<form>' +
+ '<widget name="attach_document" action="my_action"/>' +
+ '</form>',
+ });
+
+ var onFileLoadedEventName = form.$('.o_form_binary_form').attr('target');
+ // trigger _onFileLoaded function
+ $(window).trigger(onFileLoadedEventName, [{id: 5}, {id:2}]);
+
+ form.destroy();
+ });
+
+ QUnit.test('support header button as widgets on form statusbar', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<widget name="attach_document" string="Attach document"/>' +
+ '</header>' +
+ '</form>',
+ });
+
+ assert.containsOnce(form, 'button.o_attachment_button',
+ "should have 1 attach_document widget in the statusbar");
+ assert.strictEqual(form.$('span.o_attach_document').text().trim(), 'Attach document',
+ "widget should have been instantiated");
+
+ form.destroy();
+ });
+
+ QUnit.test('basic support for widgets', async function (assert) {
+ assert.expect(1);
+
+ var MyWidget = Widget.extend({
+ init: function (parent, dataPoint) {
+ this.data = dataPoint.data;
+ },
+ start: function () {
+ this.$el.text(this.data.foo + "!");
+ },
+ updateState: function (dataPoint) {
+ this.$el.text(dataPoint.data.foo + "!");
+ },
+ });
+ widgetRegistry.add('test', MyWidget);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<widget name="test"/>' +
+ '</form>',
+ });
+
+ await testUtils.fields.editInput(form.$('input[name="foo"]'), "I am alive");
+ assert.strictEqual(form.$('.o_widget').text(), 'I am alive!',
+ "widget should have been updated");
+
+ form.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('bounce edit button in readonly mode', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<div class="oe_title">' +
+ '<field name="display_name"/>' +
+ '</div>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // in readonly
+ await testUtils.dom.click(form.$('[name="display_name"]'));
+ assert.hasClass(form.$('.o_form_button_edit'), 'o_catch_attention');
+
+ // in edit
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('[name="display_name"]'));
+ // await testUtils.nextTick();
+ assert.containsNone(form, 'button.o_catch_attention:visible');
+
+ form.destroy();
+ });
+
+ QUnit.test('proper stringification in debug mode tooltip', async function (assert) {
+ assert.expect(6);
+
+ var initialDebugMode = odoo.debug;
+ odoo.debug = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id" context="{\'lang\': \'en_US\'}" ' +
+ 'attrs=\'{"invisible": [["product_id", "=", 33]]}\' ' +
+ 'widget="many2one" />' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ var $field = form.$('[name="product_id"]');
+ $field.tooltip('show', true);
+ $field.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="context"]').length,
+ 1, 'context should be present for this field');
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="context"]')[0].lastChild.wholeText.trim(),
+ "{'lang': 'en_US'}", "context should be properly stringified");
+
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="modifiers"]').length,
+ 1, 'modifiers should be present for this field');
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="modifiers"]')[0].lastChild.wholeText.trim(),
+ '{"invisible":[["product_id","=",33]]}', "modifiers should be properly stringified");
+
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="widget"]').length,
+ 1, 'widget should be present for this field');
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="widget"]')[0].lastChild.wholeText.trim(),
+ 'Many2one (many2one)', "widget description should be correct");
+
+ odoo.debug = initialDebugMode;
+ form.destroy();
+ });
+
+ QUnit.test('autoresize of text fields is done when switching to edit mode', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.fields.text_field = { string: 'Text field', type: 'text' };
+ this.data.partner.fields.text_field.default = "some\n\nmulti\n\nline\n\ntext\n";
+ this.data.partner.records[0].text_field = "a\nb\nc\nd\ne\nf";
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="display_name"/>' +
+ '<field name="text_field"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // switch to edit mode to ensure that autoresize is correctly done
+ await testUtils.form.clickEdit(form);
+ var height = form.$('.o_field_widget[name=text_field]').height();
+ // focus the field to manually trigger autoresize
+ form.$('.o_field_widget[name=text_field]').trigger('focus');
+ assert.strictEqual(form.$('.o_field_widget[name=text_field]').height(), height,
+ "autoresize should have been done automatically at rendering");
+ // next assert simply tries to ensure that the textarea isn't stucked to
+ // its minimal size, even after being focused
+ assert.ok(height > 80, "textarea should have an height of at least 80px");
+
+ // save and create a new record to ensure that autoresize is correctly done
+ await testUtils.form.clickSave(form);
+ await testUtils.form.clickCreate(form);
+ height = form.$('.o_field_widget[name=text_field]').height();
+ // focus the field to manually trigger autoresize
+ form.$('.o_field_widget[name=text_field]').trigger('focus');
+ assert.strictEqual(form.$('.o_field_widget[name=text_field]').height(), height,
+ "autoresize should have been done automatically at rendering");
+ assert.ok(height > 80, "textarea should have an height of at least 80px");
+
+ form.destroy();
+ });
+
+ QUnit.test('autoresize of text fields is done on notebook page show', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.text_field = { string: 'Text field', type: 'text' };
+ this.data.partner.fields.text_field.default = "some\n\nmulti\n\nline\n\ntext\n";
+ this.data.partner.records[0].text_field = "a\nb\nc\nd\ne\nf";
+ this.data.partner.fields.text_field_empty = { string: 'Text field', type: 'text' };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="First Page">' +
+ '<field name="foo"/>' +
+ '</page>' +
+ '<page string="Second Page">' +
+ '<field name="text_field"/>' +
+ '</page>' +
+ '<page string="Third Page">' +
+ '<field name="text_field_empty"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.hasClass(form.$('.o_notebook .nav .nav-link:first()'), 'active');
+
+ await testUtils.dom.click(form.$('.o_notebook .nav .nav-link:nth(1)'));
+ assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(1)'), 'active');
+
+ var height = form.$('.o_field_widget[name=text_field]').height();
+ assert.ok(height > 80, "textarea should have an height of at least 80px");
+
+ await testUtils.dom.click(form.$('.o_notebook .nav .nav-link:nth(2)'));
+ assert.hasClass(form.$('.o_notebook .nav .nav-link:nth(2)'), 'active');
+
+ var height = form.$('.o_field_widget[name=text_field_empty]').css('height');
+ assert.strictEqual(height, '50px', "empty textarea should have height of 50px");
+
+ form.destroy();
+ });
+
+ QUnit.test('check if the view destroys all widgets and instances', async function (assert) {
+ assert.expect(2);
+
+ var instanceNumber = 0;
+ await testUtils.mock.patch(mixins.ParentedMixin, {
+ init: function () {
+ instanceNumber++;
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ if (!this.isDestroyed()) {
+ instanceNumber--;
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ var params = {
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="trululu"/>' +
+ '<field name="timmy"/>' +
+ '<field name="product_id"/>' +
+ '<field name="priority"/>' +
+ '<field name="state"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '<field name="product_ids"/>' +
+ '<field name="p">' +
+ '<tree default_order="foo desc">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ "partner_type,false,list": '<tree><field name="name"/></tree>',
+ 'product,false,list': '<tree><field name="display_name"/></tree>',
+
+ },
+ res_id: 1,
+ };
+
+ var form = await createView(params);
+ assert.ok(instanceNumber > 0);
+
+ form.destroy();
+ assert.strictEqual(instanceNumber, 0);
+
+ await testUtils.mock.unpatch(mixins.ParentedMixin);
+ });
+
+ QUnit.test('do not change pager when discarding current record', async function (assert) {
+ assert.expect(4);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 2,
+ });
+
+ assert.strictEqual(cpHelpers.getPagerValue(form), "2",
+ 'pager should indicate that we are on second record');
+ assert.strictEqual(cpHelpers.getPagerSize(form), "2",
+ 'pager should indicate that we are on second record');
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.form.clickDiscard(form);
+
+ assert.strictEqual(cpHelpers.getPagerValue(form), "2",
+ 'pager value should not have changed');
+ assert.strictEqual(cpHelpers.getPagerSize(form), "2",
+ 'pager limit should not have changed');
+
+ form.destroy();
+ });
+
+ QUnit.test('Form view from ordered, grouped list view correct context', async function (assert) {
+ assert.expect(10);
+ this.data.partner.records[0].timmy = [12];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '<field name="timmy"/>' +
+ '</form>',
+ archs: {
+ 'partner_type,false,list':
+ '<tree>' +
+ '<field name="name"/>' +
+ '</tree>',
+ },
+ viewOptions: {
+ // Simulates coming from a list view with a groupby and filter
+ context: {
+ orderedBy: [{name: 'foo', asc:true}],
+ group_by: ['foo'],
+ }
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.model + ":" + args.method);
+ if (args.method === 'read') {
+ assert.ok(args.kwargs.context, 'context is present');
+ assert.notOk('orderedBy' in args.kwargs.context,
+ 'orderedBy not in context');
+ assert.notOk('group_by' in args.kwargs.context,
+ 'group_by not in context');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.verifySteps(['partner_type:load_views', 'partner:read', 'partner_type:read']);
+
+ form.destroy();
+ });
+
+ QUnit.test('edition in form view on a "noCache" model', async function (assert) {
+ assert.expect(5);
+
+ await testUtils.mock.patch(BasicModel, {
+ noCacheModels: BasicModel.prototype.noCacheModels.concat(['partner']),
+ });
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="display_name"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.step('write');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ core.bus.on('clear_cache', form, assert.step.bind(assert, 'clear_cache'));
+
+ await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'new value');
+ await testUtils.form.clickSave(form);
+
+ assert.verifySteps(['write', 'clear_cache']);
+
+ form.destroy();
+ await testUtils.mock.unpatch(BasicModel);
+
+ assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy
+ });
+
+ QUnit.test('creation in form view on a "noCache" model', async function (assert) {
+ assert.expect(5);
+
+ await testUtils.mock.patch(BasicModel, {
+ noCacheModels: BasicModel.prototype.noCacheModels.concat(['partner']),
+ });
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="display_name"/>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.step('create');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ core.bus.on('clear_cache', form, assert.step.bind(assert, 'clear_cache'));
+
+ await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'value');
+ await testUtils.form.clickSave(form);
+
+ assert.verifySteps(['create', 'clear_cache']);
+
+ form.destroy();
+ await testUtils.mock.unpatch(BasicModel);
+
+ assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy
+ });
+
+ QUnit.test('deletion in form view on a "noCache" model', async function (assert) {
+ assert.expect(5);
+
+ await testUtils.mock.patch(BasicModel, {
+ noCacheModels: BasicModel.prototype.noCacheModels.concat(['partner']),
+ });
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="display_name"/>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'unlink') {
+ assert.step('unlink');
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+ core.bus.on('clear_cache', form, assert.step.bind(assert, 'clear_cache'));
+
+ await cpHelpers.toggleActionMenu(form);
+ await cpHelpers.toggleMenuItem(form, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.verifySteps(['unlink', 'clear_cache']);
+
+ form.destroy();
+ await testUtils.mock.unpatch(BasicModel);
+
+ assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy
+ });
+
+ QUnit.test('reload currencies when writing on records of model res.currency', async function (assert) {
+ assert.expect(5);
+
+ this.data['res.currency'] = {
+ fields: {},
+ records: [{id: 1, display_name: "some currency"}],
+ };
+
+ var form = await createView({
+ View: FormView,
+ model: 'res.currency',
+ data: this.data,
+ arch: '<form><field name="display_name"/></form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ reloadCurrencies: function () {
+ assert.step('reload currencies');
+ },
+ },
+ });
+
+ await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'new value');
+ await testUtils.form.clickSave(form);
+
+ assert.verifySteps([
+ 'read',
+ 'write',
+ 'reload currencies',
+ 'read',
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('keep editing after call_button fail', async function (assert) {
+ assert.expect(4);
+
+ var values;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form>' +
+ '<button name="post" class="p" string="Raise Error" type="object"/>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ execute_action: function (ev) {
+ assert.ok(true, 'the action is correctly executed');
+ ev.data.on_fail();
+ },
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].p[0][2], values);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // add a row and partially fill it
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.editInput(form.$('input[name=display_name]'), 'abc');
+
+ // click button which will trigger_up 'execute_action' (this will save)
+ values = {
+ display_name: 'abc',
+ product_id: false,
+ };
+ await testUtils.dom.click(form.$('button.p'));
+ // edit the new row again and set a many2one value
+ await testUtils.dom.clickLast(form.$('.o_form_view .o_field_one2many .o_data_row .o_data_cell'));
+ await testUtils.nextTick();
+ await testUtils.fields.many2one.clickOpenDropdown('product_id');
+ await testUtils.fields.many2one.clickHighlightedItem('product_id');
+
+ assert.strictEqual(form.$('.o_field_many2one input').val(), 'xphone',
+ "value of the m2o should have been correctly updated");
+
+ values = {
+ product_id: 37,
+ };
+ await testUtils.form.clickSave(form);
+
+ form.destroy();
+ });
+
+ QUnit.test('asynchronous rendering of a widget tag', async function (assert) {
+ assert.expect(1);
+
+ var def1 = testUtils.makeTestPromise();
+
+ var MyWidget = Widget.extend({
+ willStart: function() {
+ return def1;
+ },
+ });
+
+ widgetRegistry.add('test', MyWidget);
+
+ createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<widget name="test"/>' +
+ '</form>',
+ }).then(function(form) {
+ assert.containsOnce(form, 'div.o_widget',
+ "there should be a div with widget class");
+ form.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ def1.resolve();
+ await testUtils.nextTick();
+ });
+
+ QUnit.test('no deadlock when saving with uncommitted changes', async function (assert) {
+ // Before saving a record, all field widgets are asked to commit their changes (new values
+ // that they wouldn't have sent to the model yet). This test is added alongside a bug fix
+ // ensuring that we don't end up in a deadlock when a widget actually has some changes to
+ // commit at that moment. By chance, this situation isn't reached when the user clicks on
+ // 'Save' (which is the natural way to save a record), because by clicking outside the
+ // widget, the 'change' event (this is mainly for InputFields) is triggered, and the widget
+ // notifies the model of its new value on its own initiative, before being requested to.
+ // In this test, we try to reproduce the deadlock situation by forcing the field widget to
+ // commit changes before the save. We thus manually call 'saveRecord', instead of clicking
+ // on 'Save'.
+ assert.expect(6);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="foo"/></form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ // we set a fieldDebounce to precisely mock the behavior of the webclient: changes are
+ // not sent to the model at keystrokes, but when the input is left
+ fieldDebounce: 5000,
+ });
+
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'some foo value');
+ // manually save the record, to prevent the field widget to notify the model of its new
+ // value before being requested to
+ form.saveRecord();
+
+ await testUtils.nextTick();
+
+ assert.containsOnce(form, '.o_form_readonly', "form view should be in readonly");
+ assert.strictEqual(form.$('.o_form_view').text().trim(), 'some foo value',
+ "foo field should have correct value");
+ assert.verifySteps(['onchange', 'create', 'read']);
+
+ form.destroy();
+ });
+
+ QUnit.test('save record with onchange on one2many with required field', async function (assert) {
+ // in this test, we have a one2many with a required field, whose value is
+ // set by an onchange on another field ; we manually set the value of that
+ // first field, and directly click on Save (before the onchange RPC returns
+ // and sets the value of the required field)
+ assert.expect(6);
+
+ this.data.partner.fields.foo.default = undefined;
+ this.data.partner.onchanges = {
+ display_name: function (obj) {
+ obj.foo = obj.display_name ? 'foo value' : undefined;
+ },
+ };
+
+ var onchangeDef;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<field name="foo" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return Promise.resolve(onchangeDef).then(_.constant(result));
+ }
+ if (args.method === 'create') {
+ assert.step('create');
+ assert.strictEqual(args.args[0].p[0][2].foo, 'foo value',
+ "should have wait for the onchange to return before saving");
+ }
+ return result;
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+
+ assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), '',
+ "display_name should be the empty string by default");
+ assert.strictEqual(form.$('.o_field_widget[name=foo]').val(), '',
+ "foo should be the empty string by default");
+
+ onchangeDef = testUtils.makeTestPromise(); // delay the onchange
+
+ await testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'some value');
+
+ await testUtils.form.clickSave(form);
+
+ assert.step('resolve');
+ onchangeDef.resolve();
+ await testUtils.nextTick();
+
+ assert.verifySteps(['resolve', 'create']);
+
+ form.destroy();
+ });
+
+ QUnit.test('call canBeRemoved while saving', async function (assert) {
+ assert.expect(10);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.display_name = obj.foo === 'trigger onchange' ? 'changed' : 'default';
+ },
+ };
+
+ var onchangeDef;
+ var createDef = testUtils.makeTestPromise();
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="display_name"/><field name="foo"/></form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return Promise.resolve(onchangeDef).then(_.constant(result));
+ }
+ if (args.method === 'create') {
+ return Promise.resolve(createDef).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // edit foo to trigger a delayed onchange
+ onchangeDef = testUtils.makeTestPromise();
+ await testUtils.fields.editInput(form.$('.o_field_widget[name=foo]'), 'trigger onchange');
+
+ assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'default');
+
+ // save (will wait for the onchange to return), and will be delayed as well
+ await testUtils.dom.click(form.$buttons.find('.o_form_button_save'));
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable');
+ assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'default');
+
+ // simulate a click on the breadcrumbs to leave the form view
+ form.canBeRemoved();
+ await testUtils.nextTick();
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable');
+ assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'default');
+
+ // unlock the onchange
+ onchangeDef.resolve();
+ await testUtils.nextTick();
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable');
+ assert.strictEqual(form.$('.o_field_widget[name=display_name]').val(), 'changed');
+
+ // unlock the create
+ createDef.resolve();
+ await testUtils.nextTick();
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_readonly');
+ assert.strictEqual(form.$('.o_field_widget[name=display_name]').text(), 'changed');
+ assert.containsNone(document.body, '.modal',
+ "should not display the 'Changes will be discarded' dialog");
+
+ form.destroy();
+ });
+
+ QUnit.test('call canBeRemoved twice', async function (assert) {
+ assert.expect(4);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="display_name"/><field name="foo"/></form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.containsOnce(form, '.o_form_editable');
+ await testUtils.fields.editInput(form.$('.o_field_widget[name=foo]'), 'some value');
+
+ form.canBeRemoved();
+ await testUtils.nextTick();
+ assert.containsOnce(document.body, '.modal');
+
+ form.canBeRemoved();
+ await testUtils.nextTick();
+ assert.containsOnce(document.body, '.modal');
+
+ await testUtils.dom.click($('.modal .modal-footer .btn-secondary'));
+
+ assert.containsNone(document.body, '.modal');
+
+ form.destroy();
+ });
+
+ QUnit.test('domain returned by onchange is cleared on discard', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges = {
+ foo: function () {},
+ };
+
+ var domain = ['id', '=', 1];
+ var expectedDomain = domain;
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="foo"/><field name="trululu"/></form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange' && args.args[0][0] === 1) {
+ // onchange returns a domain only on record 1
+ return Promise.resolve({
+ domain: {
+ trululu: domain,
+ },
+ });
+ }
+ if (args.method === 'name_search') {
+ assert.deepEqual(args.kwargs.args, expectedDomain);
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ ids: [1, 2],
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "should be on record 1");
+
+ // change foo to trigger the onchange
+ await testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
+
+ // open many2one dropdown to check if the domain is applied
+ await testUtils.fields.many2one.clickOpenDropdown('trululu');
+
+ // switch to another record (should ask to discard changes, and reset the domain)
+ await cpHelpers.pagerNext(form);
+
+ // discard changes by clicking on confirm in the dialog
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary:first'));
+
+ assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "should be on record 2");
+
+ // open many2one dropdown to check if the domain is applied
+ expectedDomain = [];
+ await testUtils.fields.many2one.clickOpenDropdown('trululu');
+
+ form.destroy();
+ });
+
+ QUnit.test('discard after a failed save', async function (assert) {
+ assert.expect(2);
+
+ var actionManager = await createActionManager({
+ data: this.data,
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<field name="date" required="true"/>' +
+ '<field name="foo" required="true"/>' +
+ '</form>',
+ 'partner,false,kanban': '<kanban><templates><t t-name="kanban-box">' +
+ '</t></templates></kanban>',
+ 'partner,false,search': '<search></search>',
+ },
+ actions: this.actions,
+ });
+
+ await actionManager.doAction(1);
+
+ await testUtils.dom.click('.o_control_panel .o-kanban-button-new');
+
+ //cannot save because there is a required field
+ await testUtils.dom.click('.o_control_panel .o_form_button_save');
+ await testUtils.dom.click('.o_control_panel .o_form_button_cancel');
+
+ assert.containsNone(actionManager, '.o_form_view');
+ assert.containsOnce(actionManager, '.o_kanban_view');
+
+ actionManager.destroy();
+ });
+
+ QUnit.test("one2many create record dialog shouldn't have a 'remove' button", async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<field name="foo"/>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '<form>' +
+ '<field name="foo"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickCreate(form);
+ await testUtils.dom.click(form.$('.o-kanban-button-new'));
+
+ assert.containsOnce(document.body, '.modal');
+ assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 0,
+ "shouldn't have a 'remove' button on new records");
+
+ form.destroy();
+ });
+
+ QUnit.test('edit a record in readonly and switch to edit before it is actually saved', async function (assert) {
+ assert.expect(3);
+
+ const prom = testUtils.makeTestPromise();
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `<form>
+ <field name="foo"/>
+ <field name="bar" widget="toggle_button"/>
+ </form>`,
+ mockRPC: function (route, args) {
+ const result = this._super.apply(this, arguments);
+ if (args.method === 'write') { // delay the write RPC
+ assert.deepEqual(args.args[1], {bar: false});
+ return prom.then(_.constant(result));
+ }
+ return result;
+ },
+ res_id: 1,
+ });
+
+ // edit the record (in readonly) with toogle_button widget (and delay the write RPC)
+ await testUtils.dom.click(form.$('.o_field_widget[name=bar]'));
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_readonly'); // should wait for the RPC to return
+
+ // make write RPC return
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable');
+
+ form.destroy();
+ });
+
+ QUnit.test('"bare" buttons in template should not trigger button click', async function (assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<button string="Save" class="btn-primary" special="save"/>' +
+ '<button class="mybutton">westvleteren</button>' +
+ '</form>',
+ res_id: 2,
+ intercepts: {
+ execute_action: function () {
+ assert.step('execute_action');
+ },
+ },
+ });
+ await testUtils.dom.click(form.$('.o_form_view button.btn-primary'));
+ assert.verifySteps(['execute_action']);
+ await testUtils.dom.click(form.$('.o_form_view button.mybutton'));
+ assert.verifySteps([]);
+ form.destroy();
+ });
+
+ QUnit.test('form view with inline tree view with optional fields and local storage mock', async function (assert) {
+ assert.expect(12);
+
+ var Storage = RamStorage.extend({
+ getItem: function (key) {
+ assert.step('getItem ' + key);
+ return this._super.apply(this, arguments);
+ },
+ setItem: function (key, value) {
+ assert.step('setItem ' + key + ' to ' + value);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new Storage(),
+ });
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="qux"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" optional="hide"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ view_id: 27,
+ });
+
+ var localStorageKey = 'optional_fields,partner,form,27,p,list,undefined,bar,foo';
+
+ assert.verifySteps(['getItem ' + localStorageKey]);
+
+ assert.containsN(form, 'th', 2,
+ "should have 2 th, 1 for selector, 1 for foo column");
+
+ assert.ok(form.$('th:contains(Foo)').is(':visible'),
+ "should have a visible foo field");
+
+ assert.notOk(form.$('th:contains(Bar)').is(':visible'),
+ "should not have a visible bar field");
+
+ // optional fields
+ await testUtils.dom.click(form.$('table .o_optional_columns_dropdown_toggle'));
+ assert.containsN(form, 'div.o_optional_columns div.dropdown-item', 1,
+ "dropdown have 1 optional field");
+
+ // enable optional field
+ await testUtils.dom.click(form.$('div.o_optional_columns div.dropdown-item input'));
+
+ assert.verifySteps([
+ 'setItem ' + localStorageKey + ' to ["bar"]',
+ 'getItem ' + localStorageKey,
+ ]);
+
+ assert.containsN(form, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ assert.ok(form.$('th:contains(Foo)').is(':visible'),
+ "should have a visible foo field");
+
+ assert.ok(form.$('th:contains(Bar)').is(':visible'),
+ "should have a visible bar field");
+
+ form.destroy();
+ });
+
+ QUnit.test('form view with tree_view_ref with optional fields and local storage mock', async function (assert) {
+ assert.expect(12);
+
+ var Storage = RamStorage.extend({
+ getItem: function (key) {
+ assert.step('getItem ' + key);
+ return this._super.apply(this, arguments);
+ },
+ setItem: function (key, value) {
+ assert.step('setItem ' + key + ' to ' + value);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new Storage(),
+ });
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="qux"/>' +
+ '<field name="p" context="{\'tree_view_ref\': \'34\'}"/>' +
+ '</form>',
+ archs: {
+ "partner,nope_not_this_one,list": '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ "partner,34,list": '<tree>' +
+ '<field name="foo" optional="hide"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ },
+ services: {
+ local_storage: RamStorageService,
+ },
+ view_id: 27,
+ });
+
+ var localStorageKey = 'optional_fields,partner,form,27,p,list,34,bar,foo';
+
+ assert.verifySteps(['getItem ' + localStorageKey]);
+
+ assert.containsN(form, 'th', 2,
+ "should have 2 th, 1 for selector, 1 for foo column");
+
+ assert.notOk(form.$('th:contains(Foo)').is(':visible'),
+ "should have a visible foo field");
+
+ assert.ok(form.$('th:contains(Bar)').is(':visible'),
+ "should not have a visible bar field");
+
+ // optional fields
+ await testUtils.dom.click(form.$('table .o_optional_columns_dropdown_toggle'));
+ assert.containsN(form, 'div.o_optional_columns div.dropdown-item', 1,
+ "dropdown have 1 optional field");
+
+ // enable optional field
+ await testUtils.dom.click(form.$('div.o_optional_columns div.dropdown-item input'));
+
+ assert.verifySteps([
+ 'setItem ' + localStorageKey + ' to ["foo"]',
+ 'getItem ' + localStorageKey,
+ ]);
+
+ assert.containsN(form, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ assert.ok(form.$('th:contains(Foo)').is(':visible'),
+ "should have a visible foo field");
+
+ assert.ok(form.$('th:contains(Bar)').is(':visible'),
+ "should have a visible bar field");
+
+ form.destroy();
+ });
+
+ QUnit.test('using tab in an empty required string field should not move to the next field', async function(assert) {
+ assert.expect(3);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="display_name" required="1" />' +
+ '<field name="foo" />' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ await testUtils.dom.click(form.$('input[name=display_name]'));
+ assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
+ "display_name should be focused");
+ form.$('input[name="display_name"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
+ "display_name should still be focused because it is empty and required");
+ assert.hasClass(form.$('input[name="display_name"]'), 'o_field_invalid',
+ "display_name should have the o_field_invalid class");
+ form.destroy();
+ });
+
+ QUnit.test('using tab in an empty required date field should not move to the next field', async function(assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="date" required="1" />' +
+ '<field name="foo" />' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ await testUtils.dom.click(form.$('input[name=date]'));
+ assert.strictEqual(form.$('input[name="date"]')[0], document.activeElement,
+ "display_name should be focused");
+ form.$('input[name="date"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$('input[name="date"]')[0], document.activeElement,
+ "date should still be focused because it is empty and required");
+
+ form.destroy();
+ });
+
+ QUnit.test('Edit button get the focus when pressing TAB from form', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<div class="oe_title">' +
+ '<field name="display_name"/>' +
+ '</div>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // in edit
+ await testUtils.form.clickEdit(form);
+ form.$('input[name="display_name"]').focus().trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$buttons.find('.btn-primary:visible')[0], document.activeElement,
+ "the first primary button (save) should be focused");
+ form.destroy();
+ });
+
+ QUnit.test('In Edition mode, after navigating to the last field, the default button when pressing TAB is SAVE', async function (assert) {
+ assert.expect(1);
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="state" invisible="1"/>' +
+ '<header>' +
+ '<button name="post" class="btn-primary firstButton" string="Confirm" type="object"/>' +
+ '<button name="post" class="btn-primary secondButton" string="Confirm2" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<div class="oe_title">' +
+ '<field name="display_name"/>' +
+ '</div>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('input[name="display_name"]').focus().trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$buttons.find('.o_form_button_save:visible')[0], document.activeElement,
+ "the save should be focused");
+ form.destroy();
+ });
+
+ QUnit.test('In READ mode, the default button with focus is the first primary button of the form', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="state" invisible="1"/>' +
+ '<header>' +
+ '<button name="post" class="btn-primary firstButton" string="Confirm" type="object"/>' +
+ '<button name="post" class="btn-primary secondButton" string="Confirm2" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<div class="oe_title">' +
+ '<field name="display_name"/>' +
+ '</div>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+ assert.strictEqual(form.$('button.firstButton')[0], document.activeElement,
+ "by default the focus in edit mode should go to the first primary button of the form (not edit)");
+ form.destroy();
+ });
+
+ QUnit.test('In READ mode, the default button when pressing TAB is EDIT when there is no primary button on the form', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="state" invisible="1"/>' +
+ '<header>' +
+ '<button name="post" class="not-primary" string="Confirm" type="object"/>' +
+ '<button name="post" class="not-primary" string="Confirm2" type="object"/>' +
+ '</header>' +
+ '<sheet>' +
+ '<group>' +
+ '<div class="oe_title">' +
+ '<field name="display_name"/>' +
+ '</div>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 2,
+ });
+ assert.strictEqual(form.$buttons.find('.o_form_button_edit')[0],document.activeElement,
+ "in read mode, when there are no primary buttons on the form, the default button with the focus should be edit");
+ form.destroy();
+ });
+
+ QUnit.test('In Edition mode, when an attribute is dynamically required (and not required), TAB should navigate to the next field', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 5,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('input[name="foo"]').focus();
+ $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+
+ assert.strictEqual(form.$('div[name="bar"]>input')[0], document.activeElement, "foo is not required, so hitting TAB on foo should have moved the focus to BAR");
+ form.destroy();
+ });
+
+ QUnit.test('In Edition mode, when an attribute is dynamically required, TAB should stop on the field if it is required', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 5,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ await testUtils.dom.click(form.$('div[name="bar"]>input'));
+ form.$('input[name="foo"]').focus();
+ $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+
+ assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, "foo is required, so hitting TAB on foo should keep the focus on foo");
+ form.destroy();
+ });
+
+ QUnit.test('display tooltips for save and discard buttons', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" />'+
+ '</form>',
+ });
+
+ form.$buttons.find('.o_form_buttons_edit').tooltip('show',false);
+ assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1,
+ "should have rendered a tooltip");
+ await testUtils.nextTick();
+ form.destroy();
+ });
+ QUnit.test('if the focus is on the save button, hitting ENTER should save', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" />'+
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.ok(true, "should call the /create route");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_save')
+ .focus()
+ .trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
+ await testUtils.nextTick();
+ form.destroy();
+ });
+ QUnit.test('if the focus is on the discard button, hitting ENTER should save', async function (assert) {
+ assert.expect(1);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" />'+
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.ok(true, "should call the /create route");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_cancel')
+ .focus()
+ .trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
+ await testUtils.nextTick();
+ form.destroy();
+ });
+ QUnit.test('if the focus is on the save button, hitting ESCAPE should discard', async function (assert) {
+ assert.expect(0);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" />'+
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ throw new Error('Create should not be called');
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_save')
+ .focus()
+ .trigger($.Event('keydown', {which: $.ui.keyCode.ESCAPE}));
+ await testUtils.nextTick();
+ form.destroy();
+ });
+
+ QUnit.test('resequence list lines when discardable lines are present', async function (assert) {
+ assert.expect(8);
+
+ var onchangeNum = 0;
+
+ this.data.partner.onchanges = {
+ p: function (obj) {
+ onchangeNum++;
+ obj.foo = obj.p ? obj.p.length.toString() : "0";
+ },
+ };
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '<field name="p"/>' +
+ '</form>',
+ archs: {
+ 'partner,false,list':
+ '<tree editable="bottom">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="display_name" required="1"/>' +
+ '</tree>',
+ },
+ });
+
+ assert.strictEqual(onchangeNum, 1, "one onchange happens when form is opened");
+ assert.strictEqual(form.$('[name="foo"]').val(), "0", "onchange worked there is 0 line");
+
+ // Add one line
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ form.$('.o_field_one2many input:first').focus();
+ await testUtils.nextTick();
+ form.$('.o_field_one2many input:first').val('first line').trigger('input');
+ await testUtils.nextTick();
+ await testUtils.dom.click(form.$('input[name="foo"]'));
+ assert.strictEqual(onchangeNum, 2, "one onchange happens when a line is added");
+ assert.strictEqual(form.$('[name="foo"]').val(), "1", "onchange worked there is 1 line");
+
+ // Drag and drop second line before first one (with 1 draft and invalid line)
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.dom.dragAndDrop(
+ form.$('.ui-sortable-handle').eq(0),
+ form.$('.o_data_row').last(),
+ {position: 'bottom'}
+ );
+ assert.strictEqual(onchangeNum, 3, "one onchange happens when lines are resequenced");
+ assert.strictEqual(form.$('[name="foo"]').val(), "1", "onchange worked there is 1 line");
+
+ // Add a second line
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ form.$('.o_field_one2many input:first').focus();
+ await testUtils.nextTick();
+ form.$('.o_field_one2many input:first').val('second line').trigger('input');
+ await testUtils.nextTick();
+ await testUtils.dom.click(form.$('input[name="foo"]'));
+ assert.strictEqual(onchangeNum, 4, "one onchange happens when a line is added");
+ assert.strictEqual(form.$('[name="foo"]').val(), "2", "onchange worked there is 2 lines");
+
+ form.destroy();
+ });
+
+ QUnit.test('if the focus is on the discard button, hitting ESCAPE should discard', async function (assert) {
+ assert.expect(0);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" />'+
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ throw new Error('Create should not be called');
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_cancel')
+ .focus()
+ .trigger($.Event('keydown', {which: $.ui.keyCode.ESCAPE}));
+ await testUtils.nextTick();
+ form.destroy();
+ });
+
+ QUnit.test('if the focus is on the save button, hitting TAB should not move to the next button', async function (assert) {
+ assert.expect(1);
+ /*
+ this test has only one purpose: to say that it is normal that the focus stays within a button primary even after the TAB key has been pressed.
+ It is not possible here to execute the default action of the TAB on a button : https://stackoverflow.com/questions/32428993/why-doesnt-simulating-a-tab-keypress-move-focus-to-the-next-input-field
+ so writing a test that will always succeed is not useful.
+ */
+ assert.ok("Behavior can't be tested");
+ });
+
+ QUnit.test('reload company when creating records of model res.company', async function (assert) {
+ assert.expect(6);
+
+ var form = await createView({
+ View: FormView,
+ model: 'res.company',
+ data: this.data,
+ arch: '<form><field name="name"/></form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ do_action: function (ev) {
+ assert.step('reload company');
+ assert.strictEqual(ev.data.action, "reload_context", "company view reloaded");
+ },
+ },
+ });
+
+ await testUtils.fields.editInput(form.$('input[name="name"]'), 'Test Company');
+ await testUtils.form.clickSave(form);
+
+ assert.verifySteps([
+ 'onchange',
+ 'create',
+ 'reload company',
+ 'read',
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('reload company when writing on records of model res.company', async function (assert) {
+ assert.expect(6);
+ this.data['res.company'].records = [{
+ id: 1, name: "Test Company"
+ }];
+
+ var form = await createView({
+ View: FormView,
+ model: 'res.company',
+ data: this.data,
+ arch: '<form><field name="name"/></form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ do_action: function (ev) {
+ assert.step('reload company');
+ assert.strictEqual(ev.data.action, "reload_context", "company view reloaded");
+ },
+ },
+ });
+
+ await testUtils.fields.editInput(form.$('input[name="name"]'), 'Test Company2');
+ await testUtils.form.clickSave(form);
+
+ assert.verifySteps([
+ 'read',
+ 'write',
+ 'reload company',
+ 'read',
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('company_dependent field in form view, in multi company group', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.product_id.company_dependent = true;
+ this.data.partner.fields.product_id.help = 'this is a tooltip';
+ this.data.partner.fields.foo.company_dependent = true;
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="foo"/>
+ <field name="product_id"/>
+ </group>
+ </form>`,
+ session: {
+ display_switch_company_menu: true,
+ },
+ });
+
+ const $productLabel = form.$('.o_form_label:eq(1)');
+ $productLabel.tooltip('show', false);
+ $productLabel.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(),
+ "this is a tooltip\n\nValues set here are company-specific.");
+ $productLabel.trigger($.Event('mouseleave'));
+
+ const $fooLabel = form.$('.o_form_label:first');
+ $fooLabel.tooltip('show', false);
+ $fooLabel.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(),
+ "Values set here are company-specific.");
+ $fooLabel.trigger($.Event('mouseleave'));
+
+ form.destroy();
+ });
+
+ QUnit.test('company_dependent field in form view, not in multi company group', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.product_id.company_dependent = true;
+ this.data.partner.fields.product_id.help = 'this is a tooltip';
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="product_id"/>
+ </group>
+ </form>`,
+ session: {
+ display_switch_company_menu: false,
+ },
+ });
+
+ const $productLabel = form.$('.o_form_label');
+
+ $productLabel.tooltip('show', false);
+ $productLabel.trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_help').text().trim(), "this is a tooltip");
+ $productLabel.trigger($.Event('mouseleave'));
+
+ form.destroy();
+ });
+
+ QUnit.test('reload a form view with a pie chart does not crash', async function (assert) {
+ assert.expect(3);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `<form>
+ <widget name="pie_chart" title="qux by product" attrs="{'measure\': 'qux', 'groupby': 'product_id'}"/>
+ </form>`,
+ });
+
+ assert.containsOnce(form, '.o_widget');
+ const canvasId1 = form.el.querySelector('.o_widget canvas').id;
+
+ await form.reload();
+ await testUtils.nextTick();
+
+ assert.containsOnce(form, '.o_widget');
+ const canvasId2 = form.el.querySelector('.o_widget canvas').id;
+ // A new canvas should be found in the dom
+ assert.notStrictEqual(canvasId1, canvasId2);
+
+ form.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('do not call mounted twice on children', async function (assert) {
+ assert.expect(3);
+
+ class CustomFieldComponent extends fieldRegistryOwl.get('boolean') {
+ mounted() {
+ super.mounted(...arguments);
+ assert.step('mounted');
+ }
+ willUnmount() {
+ super.willUnmount(...arguments);
+ assert.step('willUnmount');
+ }
+ }
+ fieldRegistryOwl.add('custom', CustomFieldComponent);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `<form><field name="bar" widget="custom"/></form>`,
+ });
+
+ form.destroy();
+ delete fieldRegistryOwl.map.custom;
+
+ assert.verifySteps(['mounted', 'willUnmount']);
+ });
+
+ QUnit.test("attach callbacks with long processing in __renderView", async function (assert) {
+ /**
+ * The main use case of this test is discuss, in which the FormRenderer
+ * __renderView method is overridden to perform asynchronous tasks (the
+ * update of the chatter Component) resulting in a delay between the
+ * appending of the new form content into its element and the
+ * "on_attach_callback" calls. This is the purpose of "__renderView"
+ * which is meant to do all the async work before the content is appended.
+ */
+ assert.expect(11);
+
+ let testPromise = Promise.resolve();
+
+ const Renderer = FormRenderer.extend({
+ on_attach_callback() {
+ assert.step("form.on_attach_callback");
+ this._super(...arguments);
+ },
+ async __renderView() {
+ const _super = this._super.bind(this);
+ await testPromise;
+ return _super();
+ },
+ });
+
+ // Setup custom field widget
+ fieldRegistry.add("customwidget", AbstractField.extend({
+ className: "custom-widget",
+ on_attach_callback() {
+ assert.step("widget.on_attach_callback");
+ },
+ }));
+
+ const form = await createView({
+ arch: `<form><field name="bar" widget="customwidget"/></form>`,
+ data: this.data,
+ model: 'partner',
+ res_id: 1,
+ View: FormView.extend({
+ config: Object.assign({}, FormView.prototype.config, { Renderer }),
+ }),
+ });
+
+ assert.containsOnce(form, ".custom-widget");
+ assert.verifySteps([
+ "form.on_attach_callback", // Form attached
+ "widget.on_attach_callback", // Initial widget attached
+ ]);
+
+ const initialWidget = form.$(".custom-widget")[0];
+ testPromise = testUtils.makeTestPromise();
+
+ await testUtils.form.clickEdit(form);
+
+ assert.containsOnce(form, ".custom-widget");
+ assert.strictEqual(initialWidget, form.$(".custom-widget")[0], "Widgets have yet to be replaced");
+ assert.verifySteps([]);
+
+ testPromise.resolve();
+ await testUtils.nextTick();
+
+ assert.containsOnce(form, ".custom-widget");
+ assert.notStrictEqual(initialWidget, form.$(".custom-widget")[0], "Widgets have been replaced");
+ assert.verifySteps([
+ "widget.on_attach_callback", // New widget attached
+ ]);
+
+ form.destroy();
+
+ delete fieldRegistry.map.customwidget;
+ });
+
+ QUnit.test('field "length" with value 0: can apply onchange', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.length = {string: 'Length', type: 'float', default: 0 };
+ this.data.partner.fields.foo.default = "foo default";
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="foo"/><field name="length"/></form>',
+ });
+
+ assert.strictEqual(form.$('input[name=foo]').val(), "foo default",
+ "should contain input with initial value");
+
+ form.destroy();
+ });
+
+ QUnit.test('field "length" with value 0: readonly fields are not sent when saving', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.length = {string: 'Length', type: 'float', default: 0 };
+ this.data.partner.fields.foo.default = "foo default";
+
+ // define an onchange on display_name to check that the value of readonly
+ // fields is correctly sent for onchanges
+ this.data.partner.onchanges = {
+ display_name: function () {},
+ p: function () {},
+ };
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: `<form string="Partners">
+ <field name="p">
+ <tree>
+ <field name="display_name"/>
+ </tree>
+ <form string="Partners">
+ <field name="length"/>
+ <field name="display_name"/>
+ <field name="foo" attrs="{\'readonly\': [[\'display_name\', \'=\', \'readonly\']]}"/>
+ </form>
+ </field>
+ </form>`,
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {
+ p: [[0, args.args[0].p[0][1], {length: 0, display_name: 'readonly'}]]
+ }, "should not have sent the value of the readonly field");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ assert.containsOnce(document.body, '.modal input.o_field_widget[name=foo]',
+ 'foo should be editable');
+ await testUtils.fields.editInput($('.modal .o_field_widget[name=foo]'), 'foo value');
+ await testUtils.fields.editInput($('.modal .o_field_widget[name=display_name]'), 'readonly');
+ assert.containsOnce(document.body, '.modal span.o_field_widget[name=foo]',
+ 'foo should be readonly');
+ await testUtils.dom.clickFirst($('.modal-footer .btn-primary'));
+
+ await testUtils.form.clickSave(form); // save the record
+ form.destroy();
+ });
+
+});
+
+});
diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js
new file mode 100644
index 00000000..fda4c5cb
--- /dev/null
+++ b/addons/web/static/tests/views/graph_tests.js
@@ -0,0 +1,2048 @@
+odoo.define('web.graph_view_tests', function (require) {
+"use strict";
+
+var searchUtils = require('web.searchUtils');
+var GraphView = require('web.GraphView');
+var testUtils = require('web.test_utils');
+const { sortBy } = require('web.utils');
+
+const cpHelpers = testUtils.controlPanel;
+var createView = testUtils.createView;
+var patchDate = testUtils.mock.patchDate;
+
+const { INTERVAL_OPTIONS, PERIOD_OPTIONS, COMPARISON_OPTIONS } = searchUtils;
+
+var INTERVAL_OPTION_IDS = Object.keys(INTERVAL_OPTIONS);
+
+const yearIds = [];
+const otherIds = [];
+for (const id of Object.keys(PERIOD_OPTIONS)) {
+ const option = PERIOD_OPTIONS[id];
+ if (option.granularity === 'year') {
+ yearIds.push(id);
+ } else {
+ otherIds.push(id);
+ }
+}
+const BASIC_DOMAIN_IDS = [];
+for (const yearId of yearIds) {
+ BASIC_DOMAIN_IDS.push(yearId);
+ for (const id of otherIds) {
+ BASIC_DOMAIN_IDS.push(`${yearId}__${id}`);
+ }
+}
+const GENERATOR_INDEXES = {};
+let index = 0;
+for (const id of Object.keys(PERIOD_OPTIONS)) {
+ GENERATOR_INDEXES[id] = index++;
+}
+
+const COMPARISON_OPTION_IDS = Object.keys(COMPARISON_OPTIONS);
+const COMPARISON_OPTION_INDEXES = {};
+index = 0;
+for (const comparisonOptionId of COMPARISON_OPTION_IDS) {
+ COMPARISON_OPTION_INDEXES[comparisonOptionId] = index++;
+}
+
+var f = (a, b) => [].concat(...a.map(d => b.map(e => [].concat(d, e))));
+var cartesian = (a, b, ...c) => (b ? cartesian(f(a, b), ...c) : a);
+
+var COMBINATIONS = cartesian(COMPARISON_OPTION_IDS, BASIC_DOMAIN_IDS);
+var COMBINATIONS_WITH_DATE = cartesian(COMPARISON_OPTION_IDS, BASIC_DOMAIN_IDS, INTERVAL_OPTION_IDS);
+
+QUnit.assert.checkDatasets = function (graph, keys, expectedDatasets) {
+ keys = keys instanceof Array ? keys : [keys];
+ expectedDatasets = expectedDatasets instanceof Array ?
+ expectedDatasets :
+ [expectedDatasets];
+ var datasets = graph.renderer.chart.data.datasets;
+ var actualValues = datasets.map(dataset => _.pick(dataset, keys));
+ this.pushResult({
+ result: _.isEqual(actualValues, expectedDatasets),
+ actual: actualValues,
+ expected: expectedDatasets,
+ });
+};
+
+QUnit.assert.checkLabels = function (graph, expectedLabels) {
+ var labels = graph.renderer.chart.data.labels;
+ this.pushResult({
+ result: _.isEqual(labels, expectedLabels),
+ actual: labels,
+ expected: expectedLabels,
+ });
+};
+
+QUnit.assert.checkLegend = function (graph, expectedLegendLabels) {
+ expectedLegendLabels = expectedLegendLabels instanceof Array ?
+ expectedLegendLabels :
+ [expectedLegendLabels];
+ var chart = graph.renderer.chart;
+ var actualLegendLabels = chart.config.options.legend.labels.generateLabels(chart).map(o => o.text);
+
+ this.pushResult({
+ result: _.isEqual(actualLegendLabels, expectedLegendLabels),
+ actual: actualLegendLabels,
+ expected: expectedLegendLabels,
+ });
+};
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ foo: {
+ fields: {
+ foo: {string: "Foo", type: "integer", store: true},
+ bar: {string: "bar", type: "boolean"},
+ product_id: {string: "Product", type: "many2one", relation: 'product', store: true},
+ color_id: {string: "Color", type: "many2one", relation: 'color'},
+ date: {string: "Date", type: 'date', store: true, sortable: true},
+ revenue: {string: "Revenue", type: 'integer', store: true},
+ },
+ records: [
+ {id: 1, foo: 3, bar: true, product_id: 37, date: "2016-01-01", revenue: 1},
+ {id: 2, foo: 53, bar: true, product_id: 37, color_id: 7, date: "2016-01-03", revenue: 2},
+ {id: 3, foo: 2, bar: true, product_id: 37, date: "2016-03-04", revenue: 3},
+ {id: 4, foo: 24, bar: false, product_id: 37, date: "2016-03-07", revenue: 4},
+ {id: 5, foo: 4, bar: false, product_id: 41, date: "2016-05-01", revenue: 5},
+ {id: 6, foo: 63, bar: false, product_id: 41},
+ {id: 7, foo: 42, bar: false, product_id: 41},
+ {id: 8, foo: 48, bar: false, product_id: 41, date: "2016-04-01", revenue: 8},
+ ]
+ },
+ product: {
+ fields: {
+ name: {string: "Product Name", type: "char"}
+ },
+ records: [{
+ id: 37,
+ display_name: "xphone",
+ }, {
+ id: 41,
+ display_name: "xpad",
+ }]
+ },
+ color: {
+ fields: {
+ name: {string: "Color", type: "char"}
+ },
+ records: [{
+ id: 7,
+ display_name: "red",
+ }, {
+ id: 14,
+ display_name: "black",
+ }]
+ },
+ };
+ }
+}, function () {
+
+ QUnit.module('GraphView');
+
+ QUnit.test('simple graph rendering', async function (assert) {
+ assert.expect(5);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ assert.containsOnce(graph, 'div.o_graph_canvas_container canvas',
+ "should contain a div with a canvas element");
+ assert.strictEqual(graph.renderer.state.mode, "bar",
+ "should be in bar chart mode by default");
+ assert.checkLabels(graph, [[true], [false]]);
+ assert.checkDatasets(graph,
+ ['backgroundColor', 'data', 'label', 'originIndex', 'stack'],
+ {
+ backgroundColor: "#1f77b4",
+ data: [3,5],
+ label: "Count",
+ originIndex: 0,
+ stack: "",
+ }
+ );
+ assert.checkLegend(graph, 'Count');
+
+ graph.destroy();
+ });
+
+ QUnit.test('default type attribute', async function (assert) {
+ assert.expect(1);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners" type="pie">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ assert.strictEqual(graph.renderer.state.mode, "pie", "should be in pie chart mode by default");
+
+ graph.destroy();
+ });
+
+ QUnit.test('title attribute', async function (assert) {
+ assert.expect(1);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph title="Partners" type="pie">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ assert.strictEqual(graph.$('.o_graph_renderer label').text(), "Partners",
+ "should have 'Partners as title'");
+
+ graph.destroy();
+ });
+
+ QUnit.test('field id not in groupBy', async function (assert) {
+ assert.expect(1);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners">' +
+ '<field name="id"/>' +
+ '</graph>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ assert.deepEqual(args.kwargs.groupby, [],
+ 'groupby should not contain id field');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ graph.destroy();
+ });
+
+ QUnit.test('switching mode', async function (assert) {
+ assert.expect(6);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners" type="line">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode by default");
+ assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active',
+ 'bar type button should not be active');
+ assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active',
+ 'line type button should be active');
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-mode="bar"]'));
+ assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode by default");
+ assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="line"]'), 'active',
+ 'line type button should not be active');
+ assert.hasClass(graph.$buttons.find('button[data-mode="bar"]'),'active',
+ 'bar type button should be active');
+
+ graph.destroy();
+ });
+
+ QUnit.test('displaying line chart with only 1 data point', async function (assert) {
+ assert.expect(1);
+ // this test makes sure the line chart does not crash when only one data
+ // point is displayed.
+ this.data.foo.records = this.data.foo.records.slice(0,1);
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners" type="line">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ assert.containsOnce(graph, 'canvas', "should have a canvas");
+
+ graph.destroy();
+ });
+
+ QUnit.test('displaying chart data with multiple groupbys', async function (assert) {
+ // this test makes sure the line chart shows all data labels (X axis) when
+ // it is grouped by several fields
+ assert.expect(6);
+
+ var graph = await createView({
+ View: GraphView,
+ model: 'foo',
+ data: this.data,
+ arch: '<graph type="bar"><field name="foo" /></graph>',
+ groupBy: ['product_id', 'bar', 'color_id'],
+ });
+
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+ assert.checkLegend(graph, ['true/Undefined', 'true/red', 'false/Undefined']);
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]'));
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+ assert.checkLegend(graph, ['true/Undefined', 'true/red', 'false/Undefined']);
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-mode="pie"]'));
+ assert.checkLabels(graph, [
+ ["xphone", true, "Undefined"],
+ ["xphone", true,"red"],
+ ["xphone", false, "Undefined"],
+ ["xpad", false, "Undefined"]
+ ]);
+ assert.checkLegend(graph, [
+ 'xphone/true/Undefined',
+ 'xphone/true/red',
+ 'xphone/false/Undefined',
+ 'xpad/false/Undefined'
+ ]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('switching measures', async function (assert) {
+ assert.expect(2);
+
+ var rpcCount = 0;
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Gloups">' +
+ '<field name="product_id"/>' +
+ '</graph>',
+ mockRPC: function (route, args) {
+ rpcCount++;
+ return this._super(route, args);
+ },
+ });
+ await cpHelpers.toggleMenu(graph, "Measures");
+ await cpHelpers.toggleMenuItem(graph, "Foo");
+
+ assert.checkLegend(graph, 'Foo');
+ assert.strictEqual(rpcCount, 2, "should have done 2 rpcs (2 readgroups)");
+
+ graph.destroy();
+ });
+
+ QUnit.test('no content helper (bar chart)', async function (assert) {
+ assert.expect(3);
+ this.data.foo.records = [];
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `
+ <graph string="Gloups">
+ <field name="product_id"/>
+ </graph>`,
+ viewOptions: {
+ action: {
+ help: '<p class="abc">This helper should not be displayed in graph views</p>'
+ }
+ },
+ });
+
+ assert.containsOnce(graph, 'div.o_graph_canvas_container canvas');
+ assert.containsNone(graph, 'div.o_view_nocontent');
+ assert.containsNone(graph, '.abc');
+
+ graph.destroy();
+ });
+
+ QUnit.test('no content helper (pie chart)', async function (assert) {
+ assert.expect(3);
+ this.data.foo.records = [];
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `
+ <graph type="pie">
+ <field name="product_id"/>
+ </graph>`,
+ viewOptions: {
+ action: {
+ help: '<p class="abc">This helper should not be displayed in graph views</p>'
+ }
+ },
+ });
+
+ assert.containsOnce(graph, 'div.o_graph_canvas_container canvas');
+ assert.containsNone(graph, 'div.o_view_nocontent');
+ assert.containsNone(graph, '.abc');
+
+ graph.destroy();
+ });
+
+ QUnit.test('render pie chart in comparison mode', async function (assert) {
+ assert.expect(2);
+
+ const unpatchDate = patchDate(2020, 4, 19, 1, 0, 0);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ context: { search_default_date_filter: 1, },
+ arch: '<graph type="pie">' +
+ '<field name="product_id"/>' +
+ '</graph>',
+ archs: {
+ 'foo,false,search': `
+ <search>
+ <filter name="date_filter" domain="[]" date="date" default_period="third_quarter"/>
+ </search>
+ `,
+ },
+ });
+
+ await cpHelpers.toggleComparisonMenu(graph);
+ await cpHelpers.toggleMenuItem(graph, 'Date: Previous period');
+
+ assert.containsNone(graph, 'div.o_view_nocontent',
+ "should not display the no content helper");
+ assert.checkLegend(graph, 'No data');
+
+ unpatchDate();
+ graph.destroy();
+ });
+
+ QUnit.test('no content helper after update', async function (assert) {
+ assert.expect(6);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `
+ <graph string="Gloups">
+ <field name="product_id"/>
+ </graph>`,
+ viewOptions: {
+ action: {
+ help: '<p class="abc">This helper should not be displayed in graph views</p>'
+ }
+ },
+ });
+
+ assert.containsOnce(graph, 'div.o_graph_canvas_container canvas');
+ assert.containsNone(graph, 'div.o_view_nocontent');
+ assert.containsNone(graph, '.abc');
+
+ await testUtils.graph.reload(graph, {domain: [['product_id', '<', 0]]});
+
+ assert.containsOnce(graph, 'div.o_graph_canvas_container canvas');
+ assert.containsNone(graph, 'div.o_view_nocontent');
+ assert.containsNone(graph, '.abc');
+
+ graph.destroy();
+ });
+
+ QUnit.test('can reload with other group by', async function (assert) {
+ assert.expect(2);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Gloups">' +
+ '<field name="product_id"/>' +
+ '</graph>',
+ });
+
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+
+ await testUtils.graph.reload(graph, {groupBy: ['color_id']});
+ assert.checkLabels(graph, [['Undefined'], ['red']]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('getOwnedQueryParams correctly returns mode, measure, and groupbys', async function (assert) {
+ assert.expect(4);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Gloups">' +
+ '<field name="product_id"/>' +
+ '</graph>',
+ });
+
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'bar',
+ graph_measure: '__count__',
+ graph_groupbys: ['product_id'],
+ }
+ }, "context should be correct");
+
+ await cpHelpers.toggleMenu(graph, "Measures");
+ await cpHelpers.toggleMenuItem(graph, "Foo");
+
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'bar',
+ graph_measure: 'foo',
+ graph_groupbys: ['product_id'],
+ },
+ }, "context should be correct");
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]'));
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'line',
+ graph_measure: 'foo',
+ graph_groupbys: ['product_id'],
+ },
+ }, "context should be correct");
+
+ await testUtils.graph.reload(graph, {groupBy: ['product_id', 'color_id']}); // change groupbys
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'line',
+ graph_measure: 'foo',
+ graph_groupbys: ['product_id', 'color_id'],
+ },
+ }, "context should be correct");
+
+ graph.destroy();
+ });
+
+ QUnit.test('correctly uses graph_ keys from the context', async function (assert) {
+ assert.expect(5);
+
+ var lastOne = _.last(this.data.foo.records);
+ lastOne.color_id = 14;
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph><field name="product_id"/></graph>',
+ viewOptions: {
+ context: {
+ graph_measure: 'foo',
+ graph_mode: 'line',
+ graph_groupbys: ['color_id'],
+ },
+ },
+ });
+ // check measure name is present in legend
+ assert.checkLegend(graph, 'Foo');
+ // check mode
+ assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode");
+ assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active',
+ 'bar chart button should not be active');
+ assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active',
+ 'line chart button should be active');
+ // check groupby values ('Undefined' is rejected in line chart) are in labels
+ assert.checkLabels(graph, [['red'], ['black']]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('correctly use group_by key from the context', async function (assert) {
+ assert.expect(1);
+
+ var lastOne = _.last(this.data.foo.records);
+ lastOne.color_id = 14;
+
+ var graph = await createView({
+ View: GraphView,
+ model: 'foo',
+ data: this.data,
+ arch: '<graph><field name="product_id" /></graph>',
+ groupBy: ['color_id'],
+ viewOptions: {
+ context: {
+ graph_measure: 'foo',
+ graph_mode: 'line',
+ },
+ },
+ });
+ // check groupby values ('Undefined' is rejected in line chart) are in labels
+ assert.checkLabels(graph, [['red'], ['black']]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('correctly uses graph_ keys from the context (at reload)', async function (assert) {
+ assert.expect(7);
+
+ var lastOne = _.last(this.data.foo.records);
+ lastOne.color_id = 14;
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph><field name="product_id"/></graph>',
+ });
+
+ assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode");
+ assert.hasClass(graph.$buttons.find('button[data-mode="bar"]'),'active',
+ 'bar chart button should be active');
+
+ var reloadParams = {
+ context: {
+ graph_measure: 'foo',
+ graph_mode: 'line',
+ graph_groupbys: ['color_id'],
+ },
+ };
+ await testUtils.graph.reload(graph, reloadParams);
+
+ // check measure
+ assert.checkLegend(graph, 'Foo');
+ // check mode
+ assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode");
+ assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active',
+ 'bar chart button should not be active');
+ assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active',
+ 'line chart button should be active');
+ // check groupby values ('Undefined' is rejected in line chart) are in labels
+ assert.checkLabels(graph, [['red'], ['black']]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('reload graph with correct fields', async function (assert) {
+ assert.expect(2);
+
+ var graph = await createView({
+ View: GraphView,
+ model: 'foo',
+ data: this.data,
+ arch: '<graph>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</graph>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ assert.deepEqual(args.kwargs.fields, ['product_id', 'foo'],
+ "should read the correct fields");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.graph.reload(graph, {groupBy: []});
+
+ graph.destroy();
+ });
+
+ QUnit.test('initial groupby is kept when reloading', async function (assert) {
+ assert.expect(8);
+
+ var graph = await createView({
+ View: GraphView,
+ model: 'foo',
+ data: this.data,
+ arch: '<graph>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</graph>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ assert.deepEqual(args.kwargs.groupby, ['product_id'],
+ "should group by the correct field");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+ assert.checkLegend(graph, 'Foo');
+ assert.checkDatasets(graph, 'data', {data: [82, 157]});
+
+ await testUtils.graph.reload(graph, {groupBy: []});
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+ assert.checkLegend(graph, 'Foo');
+ assert.checkDatasets(graph, 'data', {data: [82, 157]});
+
+ graph.destroy();
+ });
+
+ QUnit.test('use a many2one as a measure should work (without groupBy)', async function (assert) {
+ assert.expect(4);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners">' +
+ '<field name="product_id" type="measure"/>' +
+ '</graph>',
+ });
+ assert.containsOnce(graph, 'div.o_graph_canvas_container canvas',
+ "should contain a div with a canvas element");
+ assert.checkLabels(graph, [[]]);
+ assert.checkLegend(graph, 'Product');
+ assert.checkDatasets(graph, 'data', {data: [2]});
+
+ graph.destroy();
+ });
+
+ QUnit.test('use a many2one as a measure should work (with groupBy)', async function (assert) {
+ assert.expect(5);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners">' +
+ '<field name="bar" type="row"/>' +
+ '<field name="product_id" type="measure"/>' +
+ '</graph>',
+ });
+ assert.containsOnce(graph, 'div.o_graph_canvas_container canvas',
+ "should contain a div with a canvas element");
+
+ assert.strictEqual(graph.renderer.state.mode, "bar",
+ "should be in bar chart mode by default");
+ assert.checkLabels(graph, [[true], [false]]);
+ assert.checkLegend(graph, 'Product');
+ assert.checkDatasets(graph, 'data', {data: [1, 2]});
+
+ graph.destroy();
+ });
+
+ QUnit.test('use a many2one as a measure and as a groupby should work', async function (assert) {
+ assert.expect(3);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners">' +
+ '<field name="product_id" type="row"/>' +
+ '</graph>',
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+
+ // need to set the measure this way because it cannot be set in the
+ // arch.
+ await cpHelpers.toggleMenu(graph, "Measures");
+ await cpHelpers.toggleMenuItem(graph, "Product");
+
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+ assert.checkLegend(graph, 'Product');
+ assert.checkDatasets(graph, 'data', {data: [1, 1]});
+
+ graph.destroy();
+ });
+
+ QUnit.test('not use a many2one as a measure by default', async function (assert) {
+ assert.expect(1);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners">' +
+ '<field name="product_id"/>' +
+ '</graph>',
+ });
+ assert.notOk(graph.measures.product_id,
+ "should not have product_id as measure");
+ graph.destroy();
+ });
+
+ QUnit.test('use a many2one as a measure if set as additional fields', async function (assert) {
+ assert.expect(1);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners">' +
+ '<field name="product_id"/>' +
+ '</graph>',
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+
+ assert.ok(graph.measures.find(m => m.fieldName === 'product_id'),
+ "should have product_id as measure");
+
+ graph.destroy();
+ });
+
+ QUnit.test('measure dropdown consistency', async function (assert) {
+ assert.expect(2);
+
+ const actionManager = await testUtils.createActionManager({
+ archs: {
+ 'foo,false,graph': `
+ <graph string="Partners" type="bar">
+ <field name="foo" type="measure"/>
+ </graph>`,
+ 'foo,false,search': `<search/>`,
+ 'foo,false,kanban': `
+ <kanban>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ },
+ data: this.data,
+ });
+ await actionManager.doAction({
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[false, 'graph'], [false, 'kanban']],
+ flags: {
+ graph: {
+ additionalMeasures: ['product_id'],
+ }
+ },
+ });
+
+ assert.containsOnce(actionManager, '.o_control_panel .o_graph_measures_list',
+ "Measures dropdown is present at init"
+ );
+
+ await cpHelpers.switchView(actionManager, 'kanban');
+ await cpHelpers.switchView(actionManager, 'graph');
+
+ assert.containsOnce(actionManager, '.o_control_panel .o_graph_measures_list',
+ "Measures dropdown is present after reload"
+ );
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('graph view crash when moving from search view using Down key', async function (assert) {
+ assert.expect(1);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners" type="pie">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+ graph._giveFocus();
+ assert.ok(true,"should not generate any error");
+ graph.destroy();
+ });
+
+ QUnit.test('graph measures should be alphabetically sorted', async function (assert) {
+ assert.expect(2);
+
+ var data = this.data;
+ data.foo.fields.bouh = {string: "bouh", type: "integer"};
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: data,
+ arch: '<graph string="Partners">' +
+ '<field name="foo" type="measure"/>' +
+ '<field name="bouh" type="measure"/>' +
+ '</graph>',
+ });
+
+ await cpHelpers.toggleMenu(graph, "Measures");
+ assert.strictEqual(graph.$buttons.find('.o_graph_measures_list .dropdown-item:first').text(), 'bouh',
+ "Bouh should be the first measure");
+ assert.strictEqual(graph.$buttons.find('.o_graph_measures_list .dropdown-item:last').text(), 'Count',
+ "Count should be the last measure");
+
+ graph.destroy();
+ });
+
+ QUnit.test('Undefined should appear in bar, pie graph but not in line graph', async function (assert) {
+ assert.expect(3);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ groupBy:['date'],
+ data: this.data,
+ arch: '<graph string="Partners" type="line">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ function _indexOf (label) {
+ return graph.renderer._indexOf(graph.renderer.chart.data.labels, label);
+ }
+
+ assert.strictEqual(_indexOf(['Undefined']), -1);
+
+ await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=bar]'));
+ assert.ok(_indexOf(['Undefined']) >= 0);
+
+ await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=pie]'));
+ assert.ok(_indexOf(['Undefined']) >= 0);
+
+ graph.destroy();
+ });
+
+ QUnit.test('Undefined should appear in bar, pie graph but not in line graph with multiple groupbys', async function (assert) {
+ assert.expect(4);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ groupBy:['date', 'color_id'],
+ data: this.data,
+ arch: '<graph string="Partners" type="line">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ function _indexOf (label) {
+ return graph.renderer._indexOf(graph.renderer.chart.data.labels, label);
+ }
+
+ assert.strictEqual(_indexOf(['Undefined']), -1);
+
+ await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=bar]'));
+ assert.ok(_indexOf(['Undefined']) >= 0);
+
+ await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=pie]'));
+ var labels = graph.renderer.chart.data.labels;
+ assert.ok(labels.filter(label => /Undefined/.test(label.join(''))).length >= 1);
+
+ // Undefined should not appear after switching back to line chart
+ await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=line]'));
+ assert.strictEqual(_indexOf(['Undefined']), -1);
+
+ graph.destroy();
+ });
+
+ QUnit.test('no comparison and no groupby', async function (assert) {
+ assert.expect(9);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners" type="bar">' +
+ '<field name="foo" type="measure"/>' +
+ '</graph>',
+ });
+
+
+ assert.checkLabels(graph, [[]]);
+ assert.checkLegend(graph, 'Foo');
+ assert.checkDatasets(graph, 'data', {data: [239]});
+
+ await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]'));
+ // the labels in line chart is translated in this case to avoid to have a single
+ // point at the left of the screen and chart to seem empty.
+ assert.checkLabels(graph, [[''], [], ['']]);
+ assert.checkLegend(graph, 'Foo');
+ assert.checkDatasets(graph, 'data', {data: [undefined, 239]});
+ await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]'));
+ assert.checkLabels(graph, [[]]);
+ assert.checkLegend(graph, 'Total');
+ assert.checkDatasets(graph, 'data', {data: [239]});
+
+ graph.destroy();
+ });
+
+ QUnit.test('no comparison and one groupby', async function (assert) {
+ assert.expect(9);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners" type="bar">' +
+ '<field name="foo" type="measure"/>' +
+ '<field name="bar" type="row"/>' +
+ '</graph>',
+ });
+
+ assert.checkLabels(graph, [[true], [false]]);
+ assert.checkLegend(graph, 'Foo');
+ assert.checkDatasets(graph, 'data', {data: [58, 181]});
+
+ await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]'));
+ assert.checkLabels(graph, [[true], [false]]);
+ assert.checkLegend(graph, 'Foo');
+ assert.checkDatasets(graph, 'data', {data: [58, 181]});
+
+ await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]'));
+
+ assert.checkLabels(graph, [[true], [false]]);
+ assert.checkLegend(graph, ['true', 'false']);
+ assert.checkDatasets(graph, 'data', {data: [58, 181]});
+
+ graph.destroy();
+ });
+ QUnit.test('no comparison and two groupby', async function (assert) {
+ assert.expect(9);
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Partners" type="bar">' +
+ '<field name="foo" type="measure"/>' +
+ '</graph>',
+ groupBy: ['product_id', 'color_id'],
+ });
+
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+ assert.checkLegend(graph, ['Undefined', 'red']);
+ assert.checkDatasets(graph, ['label', 'data'], [
+ {
+ label: 'Undefined',
+ data: [29, 157],
+ },
+ {
+ label: 'red',
+ data: [53, 0],
+ }
+ ]);
+
+ await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]'));
+ assert.checkLabels(graph, [['xphone'], ['xpad']]);
+ assert.checkLegend(graph, ['Undefined', 'red']);
+ assert.checkDatasets(graph, ['label', 'data'], [
+ {
+ label: 'Undefined',
+ data: [29, 157],
+ },
+ {
+ label: 'red',
+ data: [53, 0],
+ }
+ ]);
+
+ await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]'));
+ assert.checkLabels(graph, [['xphone', 'Undefined'], ['xphone', 'red'], ['xpad', 'Undefined']]);
+ assert.checkLegend(graph, ['xphone/Undefined', 'xphone/red', 'xpad/Undefined']);
+ assert.checkDatasets(graph, ['label', 'data'], {
+ label: '',
+ data: [29, 53, 157],
+ });
+
+ graph.destroy();
+ });
+
+ QUnit.test('graph view only keeps finer groupby filter option for a given groupby', async function (assert) {
+ assert.expect(3);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ groupBy:['date:year','product_id', 'date', 'date:quarter'],
+ data: this.data,
+ arch: '<graph string="Partners" type="line">' +
+ '<field name="bar"/>' +
+ '</graph>',
+ });
+
+ assert.checkLabels(graph, [["January 2016"], ["March 2016"], ["May 2016"], ["April 2016"]]);
+ // mockReadGroup does not always sort groups -> May 2016 is before April 2016 for that reason.
+ assert.checkLegend(graph, ["xphone","xpad"]);
+ assert.checkDatasets(graph, ['label', 'data'], [
+ {
+ label: 'xphone',
+ data: [2, 2, 0, 0],
+ }, {
+ label: 'xpad',
+ data: [0, 0, 1, 1],
+ }
+ ]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('clicking on bar and pie charts triggers a do_action', async function (assert) {
+ assert.expect(5);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Foo Analysis"><field name="bar"/></graph>',
+ intercepts: {
+ do_action: function (ev) {
+ assert.deepEqual(ev.data.action, {
+ context: {},
+ domain: [["bar", "=", true]],
+ name: "Foo Analysis",
+ res_model: "foo",
+ target: 'current',
+ type: 'ir.actions.act_window',
+ view_mode: 'list',
+ views: [[false, 'list'], [false, 'form']],
+ }, "should trigger do_action with correct action parameter");
+ }
+ },
+ });
+ await testUtils.nextTick(); // wait for the graph to be rendered
+
+ // bar mode
+ assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode");
+ assert.checkDatasets(graph, ['domain'], {
+ domain: [[["bar", "=", true]], [["bar", "=", false]]],
+ });
+
+ let myChart = graph.renderer.chart;
+ let meta = myChart.getDatasetMeta(0);
+ let rectangle = myChart.canvas.getBoundingClientRect();
+ let point = meta.data[0].getCenterPoint();
+ await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
+ pageX: rectangle.left + point.x,
+ pageY: rectangle.top + point.y
+ });
+
+ // pie mode
+ await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]'));
+ assert.strictEqual(graph.renderer.state.mode, "pie", "should be in pie chart mode");
+
+ myChart = graph.renderer.chart;
+ meta = myChart.getDatasetMeta(0);
+ rectangle = myChart.canvas.getBoundingClientRect();
+ point = meta.data[0].getCenterPoint();
+ await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
+ pageX: rectangle.left + point.x,
+ pageY: rectangle.top + point.y
+ });
+
+ graph.destroy();
+ });
+
+ QUnit.test('clicking charts trigger a do_action with correct views', async function (assert) {
+ assert.expect(3);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph string="Foo Analysis"><field name="bar"/></graph>',
+ intercepts: {
+ do_action: function (ev) {
+ assert.deepEqual(ev.data.action, {
+ context: {},
+ domain: [["bar", "=", true]],
+ name: "Foo Analysis",
+ res_model: "foo",
+ target: 'current',
+ type: 'ir.actions.act_window',
+ view_mode: 'list',
+ views: [[364, 'list'], [29, 'form']],
+ }, "should trigger do_action with correct action parameter");
+ }
+ },
+ viewOptions: {
+ actionViews: [{
+ type: 'list',
+ viewID: 364,
+ }, {
+ type: 'form',
+ viewID: 29,
+ }],
+ },
+ });
+ await testUtils.nextTick(); // wait for the graph to be rendered
+
+ assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode");
+ assert.checkDatasets(graph, ['domain'], {
+ domain: [[["bar", "=", true]], [["bar", "=", false]]],
+ });
+
+ let myChart = graph.renderer.chart;
+ let meta = myChart.getDatasetMeta(0);
+ let rectangle = myChart.canvas.getBoundingClientRect();
+ let point = meta.data[0].getCenterPoint();
+ await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
+ pageX: rectangle.left + point.x,
+ pageY: rectangle.top + point.y
+ });
+
+ graph.destroy();
+ });
+
+ QUnit.test('graph view with attribute disable_linking="True"', async function (assert) {
+ assert.expect(2);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '<graph disable_linking="1"><field name="bar"/></graph>',
+ intercepts: {
+ do_action: function () {
+ throw new Error('Should not perform a do_action');
+ },
+ },
+ });
+ await testUtils.nextTick(); // wait for the graph to be rendered
+
+ assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode");
+ assert.checkDatasets(graph, ['domain'], {
+ domain: [[["bar", "=", true]], [["bar", "=", false]]],
+ });
+
+ let myChart = graph.renderer.chart;
+ let meta = myChart.getDatasetMeta(0);
+ let rectangle = myChart.canvas.getBoundingClientRect();
+ let point = meta.data[0].getCenterPoint();
+ await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
+ pageX: rectangle.left + point.x,
+ pageY: rectangle.top + point.y
+ });
+
+ graph.destroy();
+ });
+
+ QUnit.test('graph view without invisible attribute on field', async function (assert) {
+ assert.expect(4);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `<graph string="Partners"></graph>`,
+ });
+
+ await testUtils.dom.click(graph.$('.btn-group:first button'));
+ assert.containsN(graph, 'li.o_menu_item', 3,
+ "there should be three menu item in the measures dropdown (count, revenue and foo)");
+ assert.containsOnce(graph, 'li.o_menu_item a:contains("Revenue")');
+ assert.containsOnce(graph, 'li.o_menu_item a:contains("Foo")');
+ assert.containsOnce(graph, 'li.o_menu_item a:contains("Count")');
+
+ graph.destroy();
+ });
+
+ QUnit.test('graph view with invisible attribute on field', async function (assert) {
+ assert.expect(2);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `
+ <graph string="Partners">
+ <field name="revenue" invisible="1"/>
+ </graph>`,
+ });
+
+ await testUtils.dom.click(graph.$('.btn-group:first button'));
+ assert.containsN(graph, 'li.o_menu_item', 2,
+ "there should be only two menu item in the measures dropdown (count and foo)");
+ assert.containsNone(graph, 'li.o_menu_item a:contains("Revenue")');
+
+ graph.destroy();
+ });
+
+ QUnit.test('graph view sort by measure', async function (assert) {
+ assert.expect(18);
+
+ // change first record from foo as there are 4 records count for each product
+ this.data.product.records.push({ id: 38, display_name: "zphone"});
+ this.data.foo.records[7].product_id = 38;
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `<graph string="Partners" order="desc">
+ <field name="product_id"/>
+ </graph>`,
+ });
+
+ assert.containsN(graph, 'button[data-order]', 2,
+ "there should be two order buttons for sorting axis labels in bar mode");
+ assert.checkLegend(graph, 'Count', 'measure should be by count');
+ assert.hasClass(graph.$('button[data-order="desc"]'), 'active',
+ 'sorting should be applie on descending order by default when sorting="desc"');
+ assert.checkDatasets(graph, 'data', {data: [4, 3, 1]});
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]'));
+ assert.hasClass(graph.$('button[data-order="asc"]'), 'active',
+ "ascending order should be applied");
+ assert.checkDatasets(graph, 'data', {data: [1, 3, 4]});
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]'));
+ assert.hasClass(graph.$('button[data-order="desc"]'), 'active',
+ "descending order button should be active");
+ assert.checkDatasets(graph, 'data', { data: [4, 3, 1] });
+
+ // again click on descending button to deactivate order button
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]'));
+ assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active',
+ "descending order button should not be active");
+ assert.checkDatasets(graph, 'data', {data: [4, 3, 1]});
+
+ // set line mode
+ await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]'));
+ assert.containsN(graph, 'button[data-order]', 2,
+ "there should be two order buttons for sorting axis labels in line mode");
+ assert.checkLegend(graph, 'Count', 'measure should be by count');
+ assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active',
+ "descending order should be applied");
+ assert.checkDatasets(graph, 'data', {data: [4, 3, 1]});
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]'));
+ assert.hasClass(graph.$('button[data-order="asc"]'), 'active',
+ "ascending order button should be active");
+ assert.checkDatasets(graph, 'data', { data: [1, 3, 4] });
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]'));
+ assert.hasClass(graph.$('button[data-order="desc"]'), 'active',
+ "descending order button should be active");
+ assert.checkDatasets(graph, 'data', { data: [4, 3, 1] });
+
+ graph.destroy();
+ });
+
+ QUnit.test('graph view sort by measure for grouped data', async function (assert) {
+ assert.expect(9);
+
+ // change first record from foo as there are 4 records count for each product
+ this.data.product.records.push({ id: 38, display_name: "zphone", });
+ this.data.foo.records[7].product_id = 38;
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `<graph string="Partners">
+ <field name="product_id"/>
+ <field name="bar"/>
+ </graph>`,
+ });
+
+ assert.checkLegend(graph, ["true","false"], 'measure should be by count');
+ assert.containsN(graph, 'button[data-order]', 2,
+ "there should be two order buttons for sorting axis labels");
+ assert.checkDatasets(graph, 'data', [{data: [3, 0, 0]}, {data: [1, 3, 1]}]);
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]'));
+ assert.hasClass(graph.$('button[data-order="asc"]'), 'active',
+ "ascending order should be applied by default");
+ assert.checkDatasets(graph, 'data', [{ data: [1, 3, 1] }, { data: [0, 0, 3] }]);
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]'));
+ assert.hasClass(graph.$('button[data-order="desc"]'), 'active',
+ "ascending order button should be active");
+ assert.checkDatasets(graph, 'data', [{data: [1, 3, 1]}, {data: [3, 0, 0]}]);
+
+ // again click on descending button to deactivate order button
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]'));
+ assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active',
+ "descending order button should not be active");
+ assert.checkDatasets(graph, 'data', [{ data: [3, 0, 0] }, { data: [1, 3, 1] }]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('graph view sort by measure for multiple grouped data', async function (assert) {
+ assert.expect(9);
+
+ // change first record from foo as there are 4 records count for each product
+ this.data.product.records.push({ id: 38, display_name: "zphone" });
+ this.data.foo.records[7].product_id = 38;
+
+ // add few more records to data to have grouped data date wise
+ const data = [
+ {id: 9, foo: 48, bar: false, product_id: 41, date: "2016-04-01"},
+ {id: 10, foo: 49, bar: false, product_id: 41, date: "2016-04-01"},
+ {id: 11, foo: 50, bar: true, product_id: 37, date: "2016-01-03"},
+ {id: 12, foo: 50, bar: true, product_id: 41, date: "2016-01-03"},
+ ];
+
+ Object.assign(this.data.foo.records, data);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `<graph string="Partners">
+ <field name="product_id"/>
+ <field name="date"/>
+ </graph>`,
+ groupBy: ['date', 'product_id']
+ });
+
+ assert.checkLegend(graph, ["xpad","xphone","zphone"], 'measure should be by count');
+ assert.containsN(graph, 'button[data-order]', 2,
+ "there should be two order buttons for sorting axis labels");
+ assert.checkDatasets(graph, 'data', [{data: [2, 1, 1, 2]}, {data: [0, 1, 0, 0]}, {data: [1, 0, 0, 0]}]);
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="asc"]'));
+ assert.hasClass(graph.$('button[data-order="asc"]'), 'active',
+ "ascending order should be applied by default");
+ assert.checkDatasets(graph, 'data', [{ data: [1, 1, 2, 2] }, { data: [0, 1, 0, 0] }, { data: [0, 0, 0, 1] }]);
+
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]'));
+ assert.hasClass(graph.$('button[data-order="desc"]'), 'active',
+ "descending order button should be active");
+ assert.checkDatasets(graph, 'data', [{data: [1, 0, 0, 0]}, {data: [2, 2, 1, 1]}, {data: [0, 0, 1, 0]}]);
+
+ // again click on descending button to deactivate order button
+ await testUtils.dom.click(graph.$buttons.find('button[data-order="desc"]'));
+ assert.doesNotHaveClass(graph.$('button[data-order="desc"]'), 'active',
+ "descending order button should not be active");
+ assert.checkDatasets(graph, 'data', [{ data: [2, 1, 1, 2] }, { data: [0, 1, 0, 0] }, { data: [1, 0, 0, 0] }]);
+
+ graph.destroy();
+ });
+
+ QUnit.test('empty graph view with sample data', async function (assert) {
+ assert.expect(8);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `
+ <graph sample="1">
+ <field name="product_id"/>
+ <field name="date"/>
+ </graph>`,
+ domain: [['id', '<', 0]],
+ viewOptions: {
+ action: {
+ help: '<p class="abc">click to add a foo</p>'
+ }
+ },
+ });
+
+ assert.hasClass(graph.el, 'o_view_sample_data');
+ assert.containsOnce(graph, '.o_view_nocontent');
+ assert.containsOnce(graph, '.o_graph_canvas_container canvas');
+ assert.hasClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled');
+
+ await graph.reload({ domain: [] });
+
+ assert.doesNotHaveClass(graph.el, 'o_view_sample_data');
+ assert.containsNone(graph, '.o_view_nocontent');
+ assert.containsOnce(graph, '.o_graph_canvas_container canvas');
+ assert.doesNotHaveClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled');
+
+ graph.destroy();
+ });
+
+ QUnit.test('non empty graph view with sample data', async function (assert) {
+ assert.expect(8);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `
+ <graph sample="1">
+ <field name="product_id"/>
+ <field name="date"/>
+ </graph>`,
+ viewOptions: {
+ action: {
+ help: '<p class="abc">click to add a foo</p>'
+ }
+ },
+ })
+
+ assert.doesNotHaveClass(graph.el, 'o_view_sample_data');
+ assert.containsNone(graph, '.o_view_nocontent');
+ assert.containsOnce(graph, '.o_graph_canvas_container canvas');
+ assert.doesNotHaveClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled');
+
+ await graph.reload({ domain: [['id', '<', 0]] });
+
+ assert.doesNotHaveClass(graph.el, 'o_view_sample_data');
+ assert.containsOnce(graph, '.o_graph_canvas_container canvas');
+ assert.doesNotHaveClass(graph.$('.o_graph_canvas_container'), 'o_sample_data_disabled');
+ assert.containsNone(graph, '.o_view_nocontent');
+
+ graph.destroy();
+ });
+
+ QUnit.module('GraphView: comparison mode', {
+ beforeEach: async function () {
+ this.data.foo.records[0].date = '2016-12-15';
+ this.data.foo.records[1].date = '2016-12-17';
+ this.data.foo.records[2].date = '2016-11-22';
+ this.data.foo.records[3].date = '2016-11-03';
+ this.data.foo.records[4].date = '2016-12-20';
+ this.data.foo.records[5].date = '2016-12-19';
+ this.data.foo.records[6].date = '2016-12-15';
+ this.data.foo.records[7].date = undefined;
+
+ this.data.foo.records.push({id: 9, foo: 48, bar: false, product_id: 41, color_id: 7, date: "2016-12-01"});
+ this.data.foo.records.push({id: 10, foo: 17, bar: true, product_id: 41, color_id: 7, date: "2016-12-01"});
+ this.data.foo.records.push({id: 11, foo: 45, bar: true, product_id: 37, color_id: 14, date: "2016-12-01"});
+ this.data.foo.records.push({id: 12, foo: 48, bar: false, product_id: 37, color_id: 7, date: "2016-12-10"});
+ this.data.foo.records.push({id: 13, foo: 48, bar: false, product_id: undefined, color_id: 14, date: "2016-11-30"});
+ this.data.foo.records.push({id: 14, foo: -50, bar: true, product_id: 41, color_id: 14, date: "2016-12-15"});
+ this.data.foo.records.push({id: 15, foo: 53, bar: false, product_id: 41, color_id: 14, date: "2016-11-01"});
+ this.data.foo.records.push({id: 16, foo: 53, bar: true, product_id: undefined, color_id: 7, date: "2016-09-01"});
+ this.data.foo.records.push({id: 17, foo: 48, bar: false, product_id: 41, color_id: undefined, date: "2016-08-01"});
+ this.data.foo.records.push({id: 18, foo: -156, bar: false, product_id: 37, color_id: undefined, date: "2016-07-15"});
+ this.data.foo.records.push({id: 19, foo: 31, bar: false, product_id: 41, color_id: 14, date: "2016-12-15"});
+ this.data.foo.records.push({id: 20, foo: 109, bar: true, product_id: 41, color_id: 7, date: "2015-06-01"});
+
+ this.data.foo.records = sortBy(this.data.foo.records, 'date');
+
+ this.unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+
+ const graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: `
+ <graph string="Partners" type="bar">
+ <field name="foo" type="measure"/>
+ </graph>
+ `,
+ archs: {
+ 'foo,false,search': `
+ <search>
+ <filter name="date" string="Date" context="{'group_by': 'date'}"/>
+ <filter name="date_filter" string="Date Filter" date="date"/>
+ <filter name="bar" string="Bar" context="{'group_by': 'bar'}"/>
+ <filter name="product_id" string="Product" context="{'group_by': 'product_id'}"/>
+ <filter name="color_id" string="Color" context="{'group_by': 'color_id'}"/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+
+ this.graph = graph;
+
+ var checkOnlyToCheck = true;
+ var exhaustiveTest = false || checkOnlyToCheck;
+
+ var self = this;
+ async function* graphGenerator(combinations) {
+ var i = 0;
+ while (i < combinations.length) {
+ var combination = combinations[i];
+ if (!checkOnlyToCheck || combination.toString() in self.combinationsToCheck) {
+ await self.setConfig(combination);
+ }
+ if (exhaustiveTest) {
+ i++;
+ } else {
+ i += Math.floor(1 + Math.random() * 20);
+ }
+ yield combination;
+ }
+ }
+
+ this.combinationsToCheck = {};
+ this.testCombinations = async function (combinations, assert) {
+ for await (var combination of graphGenerator(combinations)) {
+ // we can check particular combinations here
+ if (combination.toString() in self.combinationsToCheck) {
+ if (self.combinationsToCheck[combination].errorMessage) {
+ assert.strictEqual(
+ graph.$('.o_nocontent_help p').eq(1).text().trim(),
+ self.combinationsToCheck[combination].errorMessage
+ );
+ } else {
+ assert.checkLabels(graph, self.combinationsToCheck[combination].labels);
+ assert.checkLegend(graph, self.combinationsToCheck[combination].legend);
+ assert.checkDatasets(graph, ['label', 'data'], self.combinationsToCheck[combination].datasets);
+ }
+ }
+ }
+ };
+
+ const GROUPBY_NAMES = ['Date', 'Bar', 'Product', 'Color'];
+
+ this.selectTimeRanges = async function (comparisonOptionId, basicDomainId) {
+ const facetEls = graph.el.querySelectorAll('.o_searchview_facet');
+ const facetIndex = [...facetEls].findIndex(el => !!el.querySelector('span.fa-filter'));
+ if (facetIndex > -1) {
+ await cpHelpers.removeFacet(graph, facetIndex);
+ }
+ const [yearId, otherId] = basicDomainId.split('__');
+ await cpHelpers.toggleFilterMenu(graph);
+ await cpHelpers.toggleMenuItem(graph, 'Date Filter');
+ await cpHelpers.toggleMenuItemOption(graph, 'Date Filter', GENERATOR_INDEXES[yearId]);
+ if (otherId) {
+ await cpHelpers.toggleMenuItemOption(graph, 'Date Filter', GENERATOR_INDEXES[otherId]);
+ }
+ const itemIndex = COMPARISON_OPTION_INDEXES[comparisonOptionId];
+ await cpHelpers.toggleComparisonMenu(graph);
+ await cpHelpers.toggleMenuItem(graph, itemIndex);
+ };
+
+ // groupby menu is assumed to be closed
+ this.selectDateIntervalOption = async function (intervalOption) {
+ intervalOption = intervalOption || 'month';
+ const optionIndex = INTERVAL_OPTION_IDS.indexOf(intervalOption);
+
+ await cpHelpers.toggleGroupByMenu(graph);
+ let wasSelected = false;
+ if (this.keepFirst) {
+ if (cpHelpers.isItemSelected(graph, 2)) {
+ wasSelected = true;
+ await cpHelpers.toggleMenuItem(graph, 2);
+ }
+ }
+ await cpHelpers.toggleMenuItem(graph, 0);
+ if (!cpHelpers.isOptionSelected(graph, 0, optionIndex)) {
+ await cpHelpers.toggleMenuItemOption(graph, 0, optionIndex);
+ }
+ for (let i = 0; i < INTERVAL_OPTION_IDS.length; i++) {
+ const oId = INTERVAL_OPTION_IDS[i];
+ if (oId !== intervalOption && cpHelpers.isOptionSelected(graph, 0, i)) {
+ await cpHelpers.toggleMenuItemOption(graph, 0, i);
+ }
+ }
+
+ if (this.keepFirst) {
+ if (wasSelected && !cpHelpers.isItemSelected(graph, 2)) {
+ await cpHelpers.toggleMenuItem(graph, 2);
+ }
+ }
+ await cpHelpers.toggleGroupByMenu(graph);
+
+ };
+
+ // groupby menu is assumed to be closed
+ this.selectGroupBy = async function (groupByName) {
+ await cpHelpers.toggleGroupByMenu(graph);
+ const index = GROUPBY_NAMES.indexOf(groupByName);
+ if (!cpHelpers.isItemSelected(graph, index)) {
+ await cpHelpers.toggleMenuItem(graph, index);
+ }
+ await cpHelpers.toggleGroupByMenu(graph);
+ };
+
+ this.setConfig = async function (combination) {
+ await this.selectTimeRanges(combination[0], combination[1]);
+ if (combination.length === 3) {
+ await self.selectDateIntervalOption(combination[2]);
+ }
+ };
+
+ this.setMode = async function (mode) {
+ await testUtils.dom.click($(`.o_control_panel .o_graph_button[data-mode="${mode}"]`));
+ };
+
+ },
+ afterEach: function () {
+ this.unpatchDate();
+ this.graph.destroy();
+ },
+ }, function () {
+ QUnit.test('comparison with one groupby equal to comparison date field', async function (assert) {
+ assert.expect(11);
+
+ this.combinationsToCheck = {
+ 'previous_period,this_year__this_month,day': {
+ labels: [...Array(6).keys()].map(x => [x]),
+ legend: ["December 2016", "November 2016"],
+ datasets: [
+ {
+ data: [110, 48, 26, 53, 63, 4],
+ label: "December 2016",
+ },
+ {
+ data: [53, 24, 2, 48],
+ label: "November 2016",
+ }
+ ],
+ }
+ };
+
+ var combinations = COMBINATIONS_WITH_DATE;
+ await this.testCombinations(combinations, assert);
+ await this.setMode('line');
+ await this.testCombinations(combinations, assert);
+ this.combinationsToCheck['previous_period,this_year__this_month,day'] = {
+ labels: [...Array(6).keys()].map(x => [x]),
+ legend: [
+ "2016-12-01,2016-11-01",
+ "2016-12-10,2016-11-03",
+ "2016-12-15,2016-11-22",
+ "2016-12-17,2016-11-30",
+ "2016-12-19",
+ "2016-12-20"
+ ],
+ datasets: [
+ {
+ data: [ 110, 48, 26, 53, 63, 4],
+ label: "December 2016",
+ },
+ {
+ data: [ 53, 24, 2, 48, 0, 0],
+ label: "November 2016",
+ }
+ ],
+ };
+ await this.setMode('pie');
+ await this.testCombinations(combinations, assert);
+
+ // isNotVisible can not have two elements so checking visibility of first element
+ assert.isNotVisible(this.graph.$('button[data-order]:first'),
+ "there should not be order button in comparison mode");
+ assert.ok(true, "No combination causes a crash");
+ });
+
+ QUnit.test('comparison with no groupby', async function (assert) {
+ assert.expect(10);
+
+ this.combinationsToCheck = {
+ 'previous_period,this_year__this_month': {
+ labels: [[]],
+ legend: ["December 2016", "November 2016"],
+ datasets: [
+ {
+ data: [304],
+ label: "December 2016",
+ },
+ {
+ data: [127],
+ label: "November 2016",
+ }
+ ],
+ }
+ };
+
+ var combinations = COMBINATIONS;
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_period,this_year__this_month'] = {
+ labels: [[''], [], ['']],
+ legend: ["December 2016", "November 2016"],
+ datasets: [
+ {
+ data: [undefined, 304],
+ label: "December 2016",
+ },
+ {
+ data: [undefined, 127],
+ label: "November 2016",
+ }
+ ],
+ };
+ await this.setMode('line');
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_period,this_year__this_month'] = {
+ labels: [[]],
+ legend: ["Total"],
+ datasets: [
+ {
+ data: [304],
+ label: "December 2016",
+ },
+ {
+ data: [127],
+ label: "November 2016",
+ }
+ ],
+ };
+ await this.setMode('pie');
+ await this.testCombinations(combinations, assert);
+
+ assert.ok(true, "No combination causes a crash");
+ });
+
+ QUnit.test('comparison with one groupby different from comparison date field', async function (assert) {
+ assert.expect(10);
+
+ this.combinationsToCheck = {
+ 'previous_period,this_year__this_month': {
+ labels: [["xpad"], ["xphone"],["Undefined"]],
+ legend: ["December 2016", "November 2016"],
+ datasets: [
+ {
+ data: [ 155, 149, 0],
+ label: "December 2016",
+ },
+ {
+ data: [ 53, 26, 48],
+ label: "November 2016",
+ }
+ ],
+ }
+ };
+
+ var combinations = COMBINATIONS;
+ await this.selectGroupBy('Product');
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_period,this_year__this_month'] = {
+ labels: [["xpad"], ["xphone"]],
+ legend: ["December 2016", "November 2016"],
+ datasets: [
+ {
+ data: [155, 149],
+ label: "December 2016",
+ },
+ {
+ data: [53, 26],
+ label: "November 2016",
+ }
+ ],
+ };
+ await this.setMode('line');
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_period,this_year__this_month'] = {
+ labels: [["xpad"], ["xphone"], ["Undefined"]],
+ legend: ["xpad", "xphone", "Undefined"],
+ datasets: [
+ {
+ data: [ 155, 149, 0],
+ label: "December 2016",
+ },
+ {
+ data: [ 53, 26, 48],
+ label: "November 2016",
+ }
+ ],
+ };
+ await this.setMode('pie');
+ await this.testCombinations(combinations, assert);
+
+ assert.ok(true, "No combination causes a crash");
+ });
+
+ QUnit.test('comparison with two groupby with first groupby equal to comparison date field', async function (assert) {
+ assert.expect(10);
+
+ this.keepFirst = true;
+ this.combinationsToCheck = {
+ 'previous_period,this_year__this_month,day': {
+ labels: [...Array(6).keys()].map(x => [x]),
+ legend: [
+ "December 2016/xpad",
+ "December 2016/xphone",
+ "November 2016/xpad",
+ "November 2016/xphone",
+ "November 2016/Undefined"
+ ],
+ datasets: [
+ {
+ data: [ 65, 0, 23, 0, 63, 4],
+ label: "December 2016/xpad"
+ },
+ {
+ data: [ 45, 48, 3, 53, 0, 0],
+ label: "December 2016/xphone"
+ },
+ {
+ data: [ 53, 0, 0, 0],
+ label: "November 2016/xpad"
+ },
+ {
+ data: [ 0, 24, 2, 0],
+ label: "November 2016/xphone"
+ },
+ {
+ data: [ 0, 0, 0, 48],
+ label: "November 2016/Undefined"
+ }
+ ]
+ }
+ };
+
+ var combinations = COMBINATIONS_WITH_DATE;
+ await this.selectGroupBy('Product');
+ await this.testCombinations(combinations, assert);
+ await this.setMode('line');
+ await this.testCombinations(combinations, assert);
+
+
+ this.combinationsToCheck['previous_period,this_year__this_month,day'] = {
+ labels: [[0, "xpad"], [0, "xphone"], [1, "xphone"], [2, "xphone"], [2, "xpad"], [3, "xphone"], [4, "xpad"], [5, "xpad"], [3, "Undefined"]],
+ legend: [
+ "2016-12-01,2016-11-01/xpad",
+ "2016-12-01,2016-11-01/xphone",
+ "2016-12-10,2016-11-03/xphone",
+ "2016-12-15,2016-11-22/xphone",
+ "2016-12-15,2016-11-22/xpad",
+ "2016-12-17,2016-11-30/xphone",
+ "2016-12-19/xpad",
+ "2016-12-20/xpad",
+ "2016-12-17,2016-11-30/Undefine..."
+ ],
+ datasets: [
+ {
+ "data": [ 65, 45, 48, 3, 23, 53, 63, 4, 0],
+ "label": "December 2016"
+ },
+ {
+ "data": [ 53, 0, 24, 2, 0, 0, 0, 0, 48],
+ "label": "November 2016"
+ }
+ ],
+ };
+
+ await this.setMode('pie');
+ await this.testCombinations(combinations, assert);
+
+ assert.ok(true, "No combination causes a crash");
+
+ this.keepFirst = false;
+ });
+
+ QUnit.test('comparison with two groupby with second groupby equal to comparison date field', async function (assert) {
+ assert.expect(8);
+
+ this.combinationsToCheck = {
+ 'previous_period,this_year,quarter': {
+ labels: [["xphone"], ["xpad"],["Undefined"]],
+ legend: [
+ "2016/Q3 2016",
+ "2016/Q4 2016",
+ "2015/Q2 2015"
+ ],
+ datasets: [
+ {
+ data: [-156, 48, 53],
+ label: "2016/Q3 2016",
+ },
+ {
+ data: [175, 208, 48],
+ label: "2016/Q4 2016",
+ },
+ {
+ data: [0, 109, 0],
+ label: "2015/Q2 2015",
+ },
+ ]
+ }
+ };
+
+ const combinations = COMBINATIONS_WITH_DATE;
+ await this.selectGroupBy('Product');
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_period,this_year,quarter'] = {
+ labels: [["xphone"], ["xpad"]],
+ legend: [
+ "2016/Q3 2016",
+ "2016/Q4 2016",
+ "2015/Q2 2015"
+ ],
+ datasets: [
+ {
+ data: [-156, 48],
+ label: "2016/Q3 2016",
+ },
+ {
+ data: [175, 208],
+ label: "2016/Q4 2016",
+ },
+ {
+ data: [0, 109],
+ label: "2015/Q2 2015",
+ },
+ ]
+ };
+ await this.setMode('line');
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_period,this_year,quarter'] = {
+ errorMessage: 'Pie chart cannot mix positive and negative numbers. ' +
+ 'Try to change your domain to only display positive results'
+ };
+ await this.setMode('pie');
+ await this.testCombinations(combinations, assert);
+
+ assert.ok(true, "No combination causes a crash");
+ });
+ QUnit.test('comparison with two groupby with no groupby equal to comparison date field', async function (assert) {
+ assert.expect(10);
+
+ this.combinationsToCheck = {
+ 'previous_year,this_year__last_month': {
+ labels: [["xpad"], ["xphone"],["Undefined"] ],
+ legend: ["November 2016/false", "November 2016/true"],
+ datasets: [
+ {
+ data: [53, 24, 48],
+ label: "November 2016/false",
+ },
+ {
+ data: [0, 2, 0],
+ label: "November 2016/true",
+ }
+ ],
+ }
+ };
+
+ var combinations = COMBINATIONS;
+ await this.selectGroupBy('Product');
+ await this.selectGroupBy('Bar');
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_year,this_year__last_month'] = {
+ labels: [["xpad"], ["xphone"] ],
+ legend: ["November 2016/false", "November 2016/true"],
+ datasets: [
+ {
+ data: [53, 24],
+ label: "November 2016/false",
+ },
+ {
+ data: [0, 2],
+ label: "November 2016/true",
+ }
+ ],
+ };
+ await this.setMode('line');
+ await this.testCombinations(combinations, assert);
+
+ this.combinationsToCheck['previous_year,this_year__last_month'] = {
+ labels:
+ [["xpad", false], ["xphone", false], ["xphone", true], ["Undefined", false], ["No data"]],
+ legend: [
+ "xpad/false",
+ "xphone/false",
+ "xphone/true",
+ "Undefined/false",
+ "No data"
+ ],
+ datasets: [
+ {
+ "data": [ 53, 24, 2, 48],
+ "label": "November 2016"
+ },
+ {
+ "data": [ undefined, undefined, undefined, undefined, 1],
+ "label": "November 2015"
+ }
+ ],
+ };
+ await this.setMode('pie');
+ await this.testCombinations(combinations, assert);
+
+ assert.ok(true, "No combination causes a crash");
+ });
+ });
+});
+});
diff --git a/addons/web/static/tests/views/kanban_benchmarks.js b/addons/web/static/tests/views/kanban_benchmarks.js
new file mode 100644
index 00000000..020067cb
--- /dev/null
+++ b/addons/web/static/tests/views/kanban_benchmarks.js
@@ -0,0 +1,92 @@
+odoo.define('web.kanban_benchmarks', function (require) {
+ "use strict";
+
+ const KanbanView = require('web.KanbanView');
+ const { createView } = require('web.test_utils');
+
+ QUnit.module('Kanban View', {
+ beforeEach: function () {
+ this.data = {
+ foo: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ bar: {string: "Bar", type: "boolean"},
+ int_field: {string: "int_field", type: "integer", sortable: true},
+ qux: {string: "my float", type: "float"},
+ },
+ records: [
+ { id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4},
+ {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13},
+ ]
+ },
+ };
+ this.arch = null;
+ this.run = function (assert) {
+ const data = this.data;
+ const arch = this.arch;
+ return new Promise(resolve => {
+ new Benchmark.Suite({})
+ .add('kanban', {
+ defer: true,
+ fn: async (deferred) => {
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'foo',
+ data,
+ arch,
+ });
+ kanban.destroy();
+ deferred.resolve();
+ },
+ })
+ .on('cycle', event => {
+ assert.ok(true, String(event.target));
+ })
+ .on('complete', resolve)
+ .run({ async: true });
+ });
+ };
+ }
+ }, function () {
+ QUnit.test('simple kanban view with 2 records', function (assert) {
+ assert.expect(1);
+
+ this.arch = `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <t t-esc="record.foo.value"/>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`;
+ return this.run(assert);
+ });
+
+ QUnit.test('simple kanban view with 200 records', function (assert) {
+ assert.expect(1);
+
+ for (let i = 2; i < 200; i++) {
+ this.data.foo.records.push({
+ id: i,
+ foo: `automated data ${i}`,
+ });
+ }
+
+ this.arch = `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <t t-esc="record.foo.value"/>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`;
+ return this.run(assert);
+ });
+ });
+});
diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js
new file mode 100644
index 00000000..2598f2bd
--- /dev/null
+++ b/addons/web/static/tests/views/kanban_model_tests.js
@@ -0,0 +1,380 @@
+odoo.define('web.kanban_model_tests', function (require) {
+"use strict";
+
+var KanbanModel = require('web.KanbanModel');
+var testUtils = require('web.test_utils');
+
+var createModel = testUtils.createModel;
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ active: {string: "Active", type: "boolean", default: true},
+ display_name: {string: "STRING", type: 'char'},
+ foo: {string: "Foo", type: 'char'},
+ bar: {string: "Bar", type: 'integer'},
+ qux: {string: "Qux", type: 'many2one', relation: 'partner'},
+ product_id: {string: "Favorite product", type: 'many2one', relation: 'product'},
+ product_ids: {string: "Favorite products", type: 'one2many', relation: 'product'},
+ category: {string: "Category M2M", type: 'many2many', relation: 'partner_type'},
+ },
+ records: [
+ {id: 1, foo: 'blip', bar: 1, product_id: 37, category: [12], display_name: "first partner"},
+ {id: 2, foo: 'gnap', bar: 2, product_id: 41, display_name: "second partner"},
+ ],
+ onchanges: {},
+ },
+ product: {
+ fields: {
+ name: {string: "Product Name", type: "char"}
+ },
+ records: [
+ {id: 37, display_name: "xphone"},
+ {id: 41, display_name: "xpad"}
+ ]
+ },
+ partner_type: {
+ fields: {
+ display_name: {string: "Partner Type", type: "char"}
+ },
+ records: [
+ {id: 12, display_name: "gold"},
+ {id: 14, display_name: "silver"},
+ {id: 15, display_name: "bronze"}
+ ]
+ },
+ };
+
+ // add related fields to category.
+ this.data.partner.fields.category.relatedFields =
+ $.extend(true, {}, this.data.partner_type.fields);
+ this.params = {
+ fields: this.data.partner.fields,
+ limit: 40,
+ modelName: 'partner',
+ openGroupByDefault: true,
+ viewType: 'kanban',
+ };
+ },
+}, function () {
+
+ QUnit.module('KanbanModel');
+
+ QUnit.test('load grouped + add a new group', async function (assert) {
+ var done = assert.async();
+ assert.expect(22);
+
+ var calledRoutes = {};
+ var model = await createModel({
+ Model: KanbanModel,
+ data: this.data,
+ mockRPC: function (route) {
+ if (!(route in calledRoutes)) {
+ calledRoutes[route] = 1;
+ } else {
+ calledRoutes[route]++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var params = _.extend(this.params, {
+ groupedBy: ['product_id'],
+ fieldNames: ['foo'],
+ });
+
+ model.load(params).then(async function (resultID) {
+ // various checks on the load result
+ var state = model.get(resultID);
+ assert.ok(_.isEqual(state.groupedBy, ['product_id']), 'should be grouped by "product_id"');
+ assert.strictEqual(state.data.length, 2, 'should have found 2 groups');
+ assert.strictEqual(state.count, 2, 'both groups contain one record');
+ var xphoneGroup = _.findWhere(state.data, {res_id: 37});
+ assert.strictEqual(xphoneGroup.model, 'partner', 'group should have correct model');
+ assert.ok(xphoneGroup, 'should have a group for res_id 37');
+ assert.ok(xphoneGroup.isOpen, '"xphone" group should be open');
+ assert.strictEqual(xphoneGroup.value, 'xphone', 'group 37 should be "xphone"');
+ assert.strictEqual(xphoneGroup.count, 1, '"xphone" group should have one record');
+ assert.strictEqual(xphoneGroup.data.length, 1, 'should have fetched the records in the group');
+ assert.ok(_.isEqual(xphoneGroup.domain[0], ['product_id', '=', 37]),
+ 'domain should be correct');
+ assert.strictEqual(xphoneGroup.limit, 40, 'limit in a group should be 40');
+
+ // add a new group
+ await model.createGroup('xpod', resultID);
+ state = model.get(resultID);
+ assert.strictEqual(state.data.length, 3, 'should now have 3 groups');
+ assert.strictEqual(state.count, 2, 'there are still 2 records');
+ var xpodGroup = _.findWhere(state.data, {value: 'xpod'});
+ assert.strictEqual(xpodGroup.model, 'partner', 'new group should have correct model');
+ assert.ok(xpodGroup, 'should have an "xpod" group');
+ assert.ok(xpodGroup.isOpen, 'new group should be open');
+ assert.strictEqual(xpodGroup.count, 0, 'new group should contain no record');
+ assert.ok(_.isEqual(xpodGroup.domain[0], ['product_id', '=', xpodGroup.res_id]),
+ 'new group should have correct domain');
+
+ // check the rpcs done
+ assert.strictEqual(Object.keys(calledRoutes).length, 3, 'three different routes have been called');
+ var nbReadGroups = calledRoutes['/web/dataset/call_kw/partner/web_read_group'];
+ var nbSearchRead = calledRoutes['/web/dataset/search_read'];
+ var nbNameCreate = calledRoutes['/web/dataset/call_kw/product/name_create'];
+ assert.strictEqual(nbReadGroups, 1, 'should have done 1 read_group');
+ assert.strictEqual(nbSearchRead, 2, 'should have done 2 search_read');
+ assert.strictEqual(nbNameCreate, 1, 'should have done 1 name_create');
+ model.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('archive/restore a column', async function (assert) {
+ var done = assert.async();
+ assert.expect(4);
+
+ var model = await createModel({
+ Model: KanbanModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/action_archive') {
+ this.data.partner.records[0].active = false;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var params = _.extend(this.params, {
+ groupedBy: ['product_id'],
+ fieldNames: ['foo'],
+ });
+
+ model.load(params).then(async function (resultID) {
+ var state = model.get(resultID);
+ var xphoneGroup = _.findWhere(state.data, {res_id: 37});
+ var xpadGroup = _.findWhere(state.data, {res_id: 41});
+ assert.strictEqual(xphoneGroup.count, 1, 'xphone group has one record');
+ assert.strictEqual(xpadGroup.count, 1, 'xpad group has one record');
+
+ // archive the column 'xphone'
+ var recordIDs = xphoneGroup.data.map(record => record.res_id);
+ await model.actionArchive(recordIDs, xphoneGroup.id);
+ state = model.get(resultID);
+ xphoneGroup = _.findWhere(state.data, {res_id: 37});
+ assert.strictEqual(xphoneGroup.count, 0, 'xphone group has no record anymore');
+ xpadGroup = _.findWhere(state.data, {res_id: 41});
+ assert.strictEqual(xpadGroup.count, 1, 'xpad group still has one record');
+ model.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('kanban model does not allow nested groups', async function (assert) {
+ var done = assert.async();
+ assert.expect(2);
+
+ var model = await createModel({
+ Model: KanbanModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.deepEqual(args.kwargs.groupby, ['product_id'],
+ "the second level of groupBy should have been removed");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var params = _.extend(this.params, {
+ groupedBy: ['product_id', 'qux'],
+ fieldNames: ['foo'],
+ });
+
+ model.load(params).then(function (resultID) {
+ var state = model.get(resultID);
+
+ assert.deepEqual(state.groupedBy, ['product_id'],
+ "the second level of groupBy should have been removed");
+
+ model.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('resequence columns and records', async function (assert) {
+ var done = assert.async();
+ assert.expect(8);
+
+ this.data.product.fields.sequence = {string: "Sequence", type: "integer"};
+ this.data.partner.fields.sequence = {string: "Sequence", type: "integer"};
+ this.data.partner.records.push({id: 3, foo: 'aaa', product_id: 37});
+
+ var nbReseq = 0;
+ var model = await createModel({
+ Model: KanbanModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ nbReseq++;
+ if (nbReseq === 1) { // resequencing columns
+ assert.deepEqual(args.ids, [41, 37],
+ "ids should be correct");
+ assert.strictEqual(args.model, 'product',
+ "model should be correct");
+ } else if (nbReseq === 2) { // resequencing records
+ assert.deepEqual(args.ids, [3, 1],
+ "ids should be correct");
+ assert.strictEqual(args.model, 'partner',
+ "model should be correct");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ var params = _.extend(this.params, {
+ groupedBy: ['product_id'],
+ fieldNames: ['foo'],
+ });
+
+ model.load(params)
+ .then(function (stateID) {
+ var state = model.get(stateID);
+ assert.strictEqual(state.data[0].res_id, 37,
+ "first group should be res_id 37");
+
+ // resequence columns
+ return model.resequence('product', [41, 37], stateID);
+ })
+ .then(function (stateID) {
+ var state = model.get(stateID);
+ assert.strictEqual(state.data[0].res_id, 41,
+ "first group should be res_id 41 after resequencing");
+ assert.strictEqual(state.data[1].data[0].res_id, 1,
+ "first record should be res_id 1");
+
+ // resequence records
+ return model.resequence('partner', [3, 1], state.data[1].id);
+ })
+ .then(function (groupID) {
+ var group = model.get(groupID);
+ assert.strictEqual(group.data[0].res_id, 3,
+ "first record should be res_id 3 after resequencing");
+
+ model.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('add record to group', async function (assert) {
+ var done = assert.async();
+ assert.expect(8);
+
+ var self = this;
+ var model = await createModel({
+ Model: KanbanModel,
+ data: this.data,
+ });
+ var params = _.extend(this.params, {
+ groupedBy: ['product_id'],
+ fieldNames: ['foo'],
+ });
+
+ model.load(params).then(function (stateID) {
+ self.data.partner.records.push({id: 3, foo: 'new record', product_id: 37});
+
+ var state = model.get(stateID);
+ assert.deepEqual(state.res_ids, [1, 2],
+ "state should have the correct res_ids");
+ assert.strictEqual(state.count, 2,
+ "state should have the correct count");
+ assert.strictEqual(state.data[0].count, 1,
+ "first group should contain one record");
+
+ return model.addRecordToGroup(state.data[0].id, 3).then(function () {
+ var state = model.get(stateID);
+ assert.deepEqual(state.res_ids, [3, 1, 2],
+ "state should have the correct res_ids");
+ assert.strictEqual(state.count, 3,
+ "state should have the correct count");
+ assert.deepEqual(state.data[0].res_ids, [3, 1],
+ "new record's id should have been added to the res_ids");
+ assert.strictEqual(state.data[0].count, 2,
+ "first group should now contain two records");
+ assert.strictEqual(state.data[0].data[0].data.foo, 'new record',
+ "new record should have been fetched");
+ });
+ }).then(function() {
+ model.destroy();
+ done();
+ })
+
+ });
+
+ QUnit.test('call get (raw: true) before loading x2many data', async function (assert) {
+ // Sometimes, get can be called on a datapoint that is currently being
+ // reloaded, and thus in a partially updated state (e.g. in a kanban
+ // view, the user interacts with the searchview, and before the view is
+ // fully reloaded, it clicks on CREATE). Ideally, this shouldn't happen,
+ // but with the sync API of get, we can't change that easily. So at most,
+ // we can ensure that it doesn't crash. Moreover, sensitive functions
+ // requesting the state for more precise information that, e.g., the
+ // count, can do that in the mutex to ensure that the state isn't
+ // currently being reloaded.
+ // In this test, we have a grouped kanban view with a one2many, whose
+ // relational data is loaded in batch, once for all groups. We call get
+ // when the search_read for the first group has returned, but not the
+ // second (and thus, the read of the one2many hasn't started yet).
+ // Note: this test can be removed as soon as search_reads are performed
+ // alongside read_group.
+ var done = assert.async();
+ assert.expect(2);
+
+ this.data.partner.records[1].product_ids = [37, 41];
+ this.params.fieldsInfo = {
+ kanban: {
+ product_ids: {
+ fieldsInfo: {
+ default: { display_name: {}, color: {} },
+ },
+ relatedFields: this.data.product.fields,
+ viewType: 'default',
+ },
+ },
+ };
+ this.params.viewType = 'kanban';
+ this.params.groupedBy = ['foo'];
+
+ var block;
+ var def = testUtils.makeTestPromise();
+ var model = await createModel({
+ Model: KanbanModel,
+ data: this.data,
+ mockRPC: function (route) {
+ var result = this._super.apply(this, arguments);
+ if (route === '/web/dataset/search_read' && block) {
+ block = false;
+ return Promise.all([def]).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ model.load(this.params).then(function (handle) {
+ block = true;
+ model.reload(handle, {});
+
+ var state = model.get(handle, {raw: true});
+ assert.strictEqual(state.count, 2);
+
+ def.resolve();
+
+ state = model.get(handle, {raw: true});
+ assert.strictEqual(state.count, 2);
+ }).then(function() {
+ model.destroy();
+ done();
+ });
+ });
+});
+
+});
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
new file mode 100644
index 00000000..1197fc9a
--- /dev/null
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -0,0 +1,7248 @@
+odoo.define('web.kanban_tests', function (require) {
+"use strict";
+
+var AbstractField = require('web.AbstractField');
+const Domain = require('web.Domain');
+var fieldRegistry = require('web.field_registry');
+const FormRenderer = require("web.FormRenderer");
+var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
+var kanbanExamplesRegistry = require('web.kanban_examples_registry');
+var KanbanRenderer = require('web.KanbanRenderer');
+var KanbanView = require('web.KanbanView');
+var mixins = require('web.mixins');
+var testUtils = require('web.test_utils');
+var Widget = require('web.Widget');
+var widgetRegistry = require('web.widget_registry');
+
+var makeTestPromise = testUtils.makeTestPromise;
+var nextTick = testUtils.nextTick;
+const cpHelpers = testUtils.controlPanel;
+var createView = testUtils.createView;
+
+QUnit.module('Views', {
+ before: function () {
+ this._initialKanbanProgressBarAnimate = KanbanColumnProgressBar.prototype.ANIMATE;
+ KanbanColumnProgressBar.prototype.ANIMATE = false;
+ },
+ after: function () {
+ KanbanColumnProgressBar.prototype.ANIMATE = this._initialKanbanProgressBarAnimate;
+ },
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ bar: {string: "Bar", type: "boolean"},
+ int_field: {string: "int_field", type: "integer", sortable: true},
+ qux: {string: "my float", type: "float"},
+ product_id: {string: "something_id", type: "many2one", relation: "product"},
+ category_ids: { string: "categories", type: "many2many", relation: 'category'},
+ state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]},
+ date: {string: "Date Field", type: 'date'},
+ datetime: {string: "Datetime Field", type: 'datetime'},
+ image: {string: "Image", type: "binary"},
+ displayed_image_id: {string: "cover", type: "many2one", relation: "ir.attachment"},
+ currency_id: {string: "Currency", type: "many2one", relation: "currency", default: 1},
+ salary: {string: "Monetary field", type: "monetary"},
+ },
+ records: [
+ {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: [], 'image': 'R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==', salary: 1750, currency_id: 1},
+ {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6], salary: 1500, currency_id: 1},
+ {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7], salary: 2000, currency_id: 2},
+ {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: [], salary: 2222, currency_id: 1},
+ ]
+ },
+ product: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ name: {string: "Display Name", type: "char"},
+ },
+ records: [
+ {id: 3, name: "hello"},
+ {id: 5, name: "xmo"},
+ ]
+ },
+ category: {
+ fields: {
+ name: {string: "Category Name", type: "char"},
+ color: {string: "Color index", type: "integer"},
+ },
+ records: [
+ {id: 6, name: "gold", color: 2},
+ {id: 7, name: "silver", color: 5},
+ ]
+ },
+ 'ir.attachment': {
+ fields: {
+ mimetype: {type: "char"},
+ name: {type: "char"},
+ res_model: {type: "char"},
+ res_id: {type: "integer"},
+ },
+ records: [
+ {id: 1, name: "1.png", mimetype: 'image/png', res_model: 'partner', res_id: 1},
+ {id: 2, name: "2.png", mimetype: 'image/png', res_model: 'partner', res_id: 2},
+ ]
+ },
+ 'currency': {
+ fields: {
+ symbol: {string: "Symbol", type: "char"},
+ position: {
+ string: "Position",
+ type: "selection",
+ selection: [['after', 'A'], ['before', 'B']],
+ },
+ },
+ records: [
+ {id: 1, display_name: "USD", symbol: '$', position: 'before'},
+ {id: 2, display_name: "EUR", symbol: '€', position: 'after'},
+ ],
+ },
+ };
+ },
+}, function () {
+
+ QUnit.module('KanbanView');
+
+ QUnit.test('basic ungrouped rendering', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<t t-esc="record.foo.value"/>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ assert.ok(args.context.bin_size,
+ "should not request direct binary payload");
+ return this._super(route, args);
+ },
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_ungrouped');
+ assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_test');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsN(kanban,'.o_kanban_ghost', 6);
+ assert.containsOnce(kanban, '.o_kanban_record:contains(gnap)');
+ kanban.destroy();
+ });
+
+ QUnit.test('basic grouped rendering', async function (assert) {
+ assert.expect(13);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ // the lazy option is important, so the server can fill in
+ // the empty groups
+ assert.ok(args.kwargs.lazy, "should use lazy read_group");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
+ assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_test');
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3);
+
+ // check available actions in kanban header's config dropdown
+ assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_kanban_toggle_fold');
+ assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_edit');
+ assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_delete');
+ assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_archive_records');
+ assert.containsNone(kanban, '.o_kanban_header:first .o_kanban_config .o_column_unarchive_records');
+
+ // the next line makes sure that reload works properly. It looks useless,
+ // but it actually test that a grouped local record can be reloaded without
+ // changing its result.
+ await kanban.reload(kanban);
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('basic grouped rendering with active field (archivable by default)', async function (assert) {
+ // var done = assert.async();
+ assert.expect(9);
+
+ // add active field on partner model and make all records active
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="active"/>' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/action_archive') {
+ var partnerIDS = args.args[0];
+ var records = this.data.partner.records
+ _.each(partnerIDS, function(partnerID) {
+ _.find(records, function (record) {
+ return record.id === partnerID;
+ }).active = false;
+ })
+ this.data.partner.records[0].active;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // check archive/restore all actions in kanban header's config dropdown
+ assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_archive_records');
+ assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_unarchive_records');
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ // archive the records of the first column
+ assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3);
+
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records'));
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
+ await testUtils.modal.clickButton('Cancel');
+ assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3, "still last column should contain 3 records");
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records'));
+ assert.ok($('.modal').length, 'a confirm modal should be displayed');
+ await testUtils.modal.clickButton('Ok');
+ assert.containsNone(kanban, '.o_kanban_group:last .o_kanban_record', "last column should not contain any records");
+ envIDs = [4];
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+ kanban.destroy();
+ });
+
+ QUnit.test('basic grouped rendering with active field and archive enabled (archivable true)', async function (assert) {
+ assert.expect(7);
+
+ // add active field on partner model and make all records active
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" archivable="true">' +
+ '<field name="active"/>' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/action_archive') {
+ var partnerIDS = args.args[0];
+ var records = this.data.partner.records
+ _.each(partnerIDS, function(partnerID) {
+ _.find(records, function (record) {
+ return record.id === partnerID;
+ }).active = false;
+ })
+ this.data.partner.records[0].active;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // check archive/restore all actions in kanban header's config dropdown
+ assert.ok(kanban.$('.o_kanban_header:first .o_kanban_config .o_column_archive_records').length, "should be able to archive all the records");
+ assert.ok(kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive_records').length, "should be able to restore all the records");
+
+ // archive the records of the first column
+ assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3,
+ "last column should contain 3 records");
+ envIDs = [4];
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records'));
+ assert.ok($('.modal').length, 'a confirm modal should be displayed');
+ await testUtils.modal.clickButton('Cancel'); // Click on 'Cancel'
+ assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3, "still last column should contain 3 records");
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records'));
+ assert.ok($('.modal').length, 'a confirm modal should be displayed');
+ await testUtils.modal.clickButton('Ok'); // Click on 'Ok'
+ assert.containsNone(kanban, '.o_kanban_group:last .o_kanban_record', "last column should not contain any records");
+ kanban.destroy();
+ });
+
+ QUnit.test('basic grouped rendering with active field and hidden archive buttons (archivable false)', async function (assert) {
+ assert.expect(2);
+
+ // add active field on partner model and make all records active
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" archivable="false">' +
+ '<field name="active"/>' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ // check archive/restore all actions in kanban header's config dropdown
+ assert.strictEqual(
+ kanban.$('.o_kanban_header:first .o_kanban_config .o_column_archive_records').length, 0,
+ "should not be able to archive all the records");
+ assert.strictEqual(
+ kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive_records').length, 0,
+ "should not be able to restore all the records");
+ kanban.destroy();
+ });
+
+ QUnit.test('context can be used in kanban template', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div>' +
+ '<t t-if="context.some_key">' +
+ '<field name="foo"/>' +
+ '</t>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ context: {some_key: 1},
+ domain: [['id', '=', 1]],
+ });
+
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1,
+ "there should be one record");
+ assert.strictEqual(form.$('.o_kanban_record span:contains(yop)').length, 1,
+ "condition in the kanban template should have been correctly evaluated");
+
+ form.destroy();
+ });
+
+ QUnit.test('pager should be hidden in grouped mode', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.containsNone(kanban, '.o_pager');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('pager, ungrouped, with default limit', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.limit, 40, "default limit should be 40 in Kanban");
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_pager');
+ assert.strictEqual(cpHelpers.getPagerSize(kanban), "4", "pager's size should be 4");
+ kanban.destroy();
+ });
+
+ QUnit.test('pager, ungrouped, with limit given in options', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.limit, 2, "limit should be 2");
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-2", "pager's limit should be 2");
+ assert.strictEqual(cpHelpers.getPagerSize(kanban), "4", "pager's size should be 4");
+ kanban.destroy();
+ });
+
+ QUnit.test('pager, ungrouped, with limit set on arch and given in options', async function (assert) {
+ assert.expect(3);
+
+ // the limit given in the arch should take the priority over the one given in options
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" limit="3">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.limit, 3, "limit should be 3");
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-3", "pager's limit should be 3");
+ assert.strictEqual(cpHelpers.getPagerSize(kanban), "4", "pager's size should be 4");
+ kanban.destroy();
+ });
+
+ QUnit.test('pager, ungrouped, deleting all records from last page should move to previous page', async function (assert) {
+ assert.expect(5);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ `<kanban class="o_kanban_test" limit="3">
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <div><a role="menuitem" type="delete" class="dropdown-item">Delete</a></div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ });
+
+ assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-3",
+ "should have 3 records on current page");
+ assert.strictEqual(cpHelpers.getPagerSize(kanban), "4",
+ "should have 4 records");
+
+ // move to next page
+ await cpHelpers.pagerNext(kanban);
+ assert.strictEqual(cpHelpers.getPagerValue(kanban), "4-4",
+ "should be on second page");
+
+ // delete a record
+ await testUtils.dom.click(kanban.$('.o_kanban_record:first a:first'));
+ await testUtils.dom.click($('.modal-footer button:first'));
+ assert.strictEqual(cpHelpers.getPagerValue(kanban), "1-3",
+ "should have 1 page only");
+ assert.strictEqual(cpHelpers.getPagerSize(kanban), "3",
+ "should have 4 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('create in grouped on m2o', async function (assert) {
+ assert.expect(5);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'),'ui-sortable',
+ "columns are sortable when grouped by a m2o field");
+ assert.hasClass(kanban.$buttons.find('.o-kanban-button-new'),'btn-primary',
+ "'create' button should be btn-primary for grouped kanban with at least one column");
+ assert.hasClass(kanban.$('.o_kanban_view > div:last'),'o_column_quick_create',
+ "column quick create should be enabled when grouped by a many2one field)");
+
+ await testUtils.kanban.clickCreate(kanban); // Click on 'Create'
+ assert.hasClass(kanban.$('.o_kanban_group:first() > div:nth(1)'),'o_kanban_quick_create',
+ "clicking on create should open the quick_create in the first column");
+
+ assert.ok(kanban.$('span.o_column_title:contains(hello)').length,
+ "should have a column title with a value from the many2one");
+ kanban.destroy();
+ });
+
+ QUnit.test('create in grouped on char', async function (assert) {
+ assert.expect(4);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['foo'],
+ });
+
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'ui-sortable',
+ "columns aren't sortable when not grouped by a m2o field");
+ assert.containsN(kanban, '.o_kanban_group', 3, "should have " + 3 + " columns");
+ assert.strictEqual(kanban.$('.o_kanban_group:first() .o_column_title').text(), "yop",
+ "'yop' column should be the first column");
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view > div:last'), 'o_column_quick_create',
+ "column quick create should be disabled when not grouped by a many2one field)");
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record without quick_create_view', async function (assert) {
+ assert.expect(16);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'name_create') {
+ assert.strictEqual(args.args[0], 'new partner',
+ "should send the correct value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain one record");
+
+ // click on 'Create' -> should open the quick create in the first column
+ await testUtils.kanban.clickCreate(kanban);
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+
+ assert.strictEqual($quickCreate.length, 1,
+ "should have a quick create element in the first column");
+ assert.strictEqual($quickCreate.find('.o_form_view.o_xxs_form_view').length, 1,
+ "should have rendered an XXS form view");
+ assert.strictEqual($quickCreate.find('input').length, 1,
+ "should have only one input");
+ assert.hasClass($quickCreate.find('input'), 'o_required_modifier',
+ "the field should be required");
+ assert.strictEqual($quickCreate.find('input[placeholder=Title]').length, 1,
+ "input placeholder should be 'Title'");
+
+ // fill the quick create and validate
+ await testUtils.fields.editInput($quickCreate.find('input'), 'new partner');
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should contain two records");
+
+ assert.verifySteps([
+ 'web_read_group', // initial read_group
+ '/web/dataset/search_read', // initial search_read (first column)
+ '/web/dataset/search_read', // initial search_read (second column)
+ 'onchange', // quick create
+ 'name_create', // should perform a name_create to create the record
+ 'read', // read the created record
+ 'onchange', // reopen the quick create automatically
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record with quick_create_view', async function (assert) {
+ assert.expect(19);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="state" widget="priority"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {
+ foo: 'new partner',
+ int_field: 4,
+ state: 'def',
+ }, "should send the correct values");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_control_panel', 'should have one control panel');
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain one record");
+
+ // click on 'Create' -> should open the quick create in the first column
+ await testUtils.kanban.clickCreate(kanban);
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+
+ assert.strictEqual($quickCreate.length, 1,
+ "should have a quick create element in the first column");
+ assert.strictEqual($quickCreate.find('.o_form_view.o_xxs_form_view').length, 1,
+ "should have rendered an XXS form view");
+ assert.containsOnce(kanban, '.o_control_panel', 'should not have instantiated an extra control panel');
+ assert.strictEqual($quickCreate.find('input').length, 2,
+ "should have two inputs");
+ assert.strictEqual($quickCreate.find('.o_field_widget').length, 3,
+ "should have rendered three widgets");
+
+ // fill the quick create and validate
+ await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner');
+ await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=int_field]'), '4');
+ await testUtils.dom.click($quickCreate.find('.o_field_widget[name=state] .o_priority_star:first'));
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should contain two records");
+
+ assert.verifySteps([
+ 'web_read_group', // initial read_group
+ '/web/dataset/search_read', // initial search_read (first column)
+ '/web/dataset/search_read', // initial search_read (second column)
+ 'load_views', // form view in quick create
+ 'onchange', // quick create
+ 'create', // should perform a create to create the record
+ 'read', // read the created record
+ 'load_views', // form view in quick create (is actually in cache)
+ 'onchange', // reopen the quick create automatically
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped on m2o (no quick_create_view)', async function (assert) {
+ assert.expect(12);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'name_create') {
+ assert.strictEqual(args.args[0], 'new partner',
+ "should send the correct value");
+ assert.deepEqual(args.kwargs.context, {
+ default_product_id: 3,
+ default_qux: 2.5,
+ }, "should send the correct context");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ context: {default_qux: 2.5},
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should contain two records");
+
+ // click on 'Create', fill the quick create and validate
+ await testUtils.kanban.clickCreate(kanban);
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'new partner');
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3,
+ "first column should contain three records");
+
+ assert.verifySteps([
+ 'web_read_group', // initial read_group
+ '/web/dataset/search_read', // initial search_read (first column)
+ '/web/dataset/search_read', // initial search_read (second column)
+ 'onchange', // quick create
+ 'name_create', // should perform a name_create to create the record
+ 'read', // read the created record
+ 'onchange', // reopen the quick create automatically
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped on m2o (with quick_create_view)', async function (assert) {
+ assert.expect(14);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="state" widget="priority"/>' +
+ '</form>',
+ },
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {
+ foo: 'new partner',
+ int_field: 4,
+ state: 'def',
+ }, "should send the correct values");
+ assert.deepEqual(args.kwargs.context, {
+ default_product_id: 3,
+ default_qux: 2.5,
+ }, "should send the correct context");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ context: {default_qux: 2.5},
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should contain two records");
+
+ // click on 'Create', fill the quick create and validate
+ await testUtils.kanban.clickCreate(kanban);
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner');
+ await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=int_field]'), '4');
+ await testUtils.dom.click($quickCreate.find('.o_field_widget[name=state] .o_priority_star:first'));
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3,
+ "first column should contain three records");
+
+ assert.verifySteps([
+ 'web_read_group', // initial read_group
+ '/web/dataset/search_read', // initial search_read (first column)
+ '/web/dataset/search_read', // initial search_read (second column)
+ 'load_views', // form view in quick create
+ 'onchange', // quick create
+ 'create', // should perform a create to create the record
+ 'read', // read the created record
+ 'load_views', // form view in quick create (is actually in cache)
+ 'onchange', // reopen the quick create automatically
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record with default values and onchanges', async function (assert) {
+ assert.expect(10);
+
+ this.data.partner.fields.int_field.default = 4;
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ if (obj.foo) {
+ obj.int_field = 8;
+ }
+ },
+ };
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // click on 'Create' -> should open the quick create in the first column
+ await testUtils.kanban.clickCreate(kanban);
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+
+ assert.strictEqual($quickCreate.length, 1,
+ "should have a quick create element in the first column");
+ assert.strictEqual($quickCreate.find('.o_field_widget[name=int_field]').val(), '4',
+ "default value should be set");
+
+ // fill the 'foo' field -> should trigger the onchange
+ await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner');
+
+ assert.strictEqual($quickCreate.find('.o_field_widget[name=int_field]').val(), '8',
+ "onchange should have been triggered");
+
+ assert.verifySteps([
+ 'web_read_group', // initial read_group
+ '/web/dataset/search_read', // initial search_read (first column)
+ '/web/dataset/search_read', // initial search_read (second column)
+ 'load_views', // form view in quick create
+ 'onchange', // quick create
+ 'onchange', // onchange due to 'foo' field change
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record with quick_create_view: modifiers', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo" required="1"/>' +
+ '<field name="int_field" attrs=\'{"invisible": [["foo", "=", false]]}\'/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ });
+
+ // create a new record
+ await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_quick_add'));
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+
+ assert.hasClass($quickCreate.find('.o_field_widget[name=foo]'),'o_required_modifier',
+ "foo field should be required");
+ assert.hasClass($quickCreate.find('.o_field_widget[name=int_field]'),'o_invisible_modifier',
+ "int_field should be invisible");
+
+ // fill 'foo' field
+ await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=foo]'), 'new partner');
+
+ assert.doesNotHaveClass($quickCreate.find('.o_field_widget[name=int_field]'), 'o_invisible_modifier',
+ "int_field should now be visible");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record and change state in grouped mode', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.kanban_state = {
+ string: "Kanban State",
+ type: "selection",
+ selection: [["normal", "Grey"], ["done", "Green"], ["blocked", "Red"]],
+ };
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '<div class="oe_kanban_bottom_right">' +
+ '<field name="kanban_state" widget="state_selection"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['foo'],
+ });
+
+ // Quick create kanban record
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ var $quickAdd = kanban.$('.o_kanban_quick_create');
+ $quickAdd.find('.o_input').val('Test');
+ await testUtils.dom.click($quickAdd.find('.o_kanban_add'));
+
+ // Select state in kanban
+ await testUtils.dom.click(kanban.$('.o_status').first());
+ await testUtils.dom.click(kanban.$('.o_selection .dropdown-item:first'));
+ assert.hasClass(kanban.$('.o_status').first(),'o_status_green',
+ "Kanban state should be done (Green)");
+ kanban.destroy();
+ });
+
+ QUnit.test('window resize should not change quick create form size', async function (assert) {
+ assert.expect(2);
+
+ testUtils.mock.patch(FormRenderer, {
+ start: function () {
+ this._super.apply(this, arguments);
+ window.addEventListener("resize", this._applyFormSizeClass.bind(this));
+ },
+
+ });
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ // click to add an element and cancel the quick creation by pressing ESC
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_header .o_kanban_quick_add i'));
+
+ const quickCreate = kanban.el.querySelector('.o_kanban_quick_create');
+ assert.hasClass(quickCreate.querySelector('.o_form_view'), "o_xxs_form_view");
+
+ // trigger window resize explicitly to call _applyFormSizeClass
+ window.dispatchEvent(new Event('resize'));
+ assert.hasClass(quickCreate.querySelector('.o_form_view'), 'o_xxs_form_view');
+
+ kanban.destroy();
+ testUtils.mock.unpatch(FormRenderer);
+ });
+
+ QUnit.test('quick create record: cancel and validate without using the buttons', async function (assert) {
+ assert.expect(9);
+
+ var nbRecords = 4;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(kanban.exportState().resIds.length, nbRecords);
+
+ // click to add an element and cancel the quick creation by pressing ESC
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+
+ var $quickCreate = kanban.$('.o_kanban_quick_create');
+ assert.strictEqual($quickCreate.length, 1, "should have a quick create element");
+
+ $quickCreate.find('input').trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ESCAPE,
+ which: $.ui.keyCode.ESCAPE,
+ }));
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "should have destroyed the quick create element");
+
+ // click to add and element and click outside, should cancel the quick creation
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first'));
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "the quick create should be destroyed when the user clicks outside");
+
+ // click to input and drag the mouse outside, should not cancel the quick creation
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ $quickCreate = kanban.$('.o_kanban_quick_create');
+ await testUtils.dom.triggerMouseEvent($quickCreate.find('input'), 'mousedown');
+ await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should not have been destroyed after clicking outside");
+
+ // click to really add an element
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ $quickCreate = kanban.$('.o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'new partner');
+
+ // clicking outside should no longer destroy the quick create as it is dirty
+ await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first'));
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should not have been destroyed");
+
+ // confirm by pressing ENTER
+ nbRecords = 5;
+ $quickCreate.find('input').trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ENTER,
+ which: $.ui.keyCode.ENTER,
+ }));
+
+ await nextTick();
+ assert.strictEqual(this.data.partner.records.length, 5,
+ "should have created a partner");
+ assert.strictEqual(_.last(this.data.partner.records).name, "new partner",
+ "should have correct name");
+ assert.strictEqual(kanban.exportState().resIds.length, nbRecords);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: validate with ENTER', async function (assert) {
+ // in this test, we accurately mock the behavior of the webclient by specifying a
+ // fieldDebounce > 0, meaning that the changes in an InputField aren't notified to the model
+ // on 'input' events, but they wait for the 'change' event (or a call to 'commitChanges',
+ // e.g. triggered by a navigation event)
+ // in this scenario, the call to 'commitChanges' actually does something (i.e. it notifies
+ // the new value of the char field), whereas it does nothing if the changes are notified
+ // directly
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ fieldDebounce: 5000,
+ });
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should have 4 records at the beginning");
+
+ // add an element and confirm by pressing ENTER
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ await testUtils.kanban.quickCreate(kanban, 'new partner', 'foo');
+ // triggers a navigation event, leading to the 'commitChanges' and record creation
+
+ assert.containsN(kanban, '.o_kanban_record', 5,
+ "should have created a new record");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '',
+ "quick create should now be empty");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: prevent multiple adds with ENTER', async function (assert) {
+ assert.expect(9);
+
+ var prom = makeTestPromise();
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ // add a fieldDebounce to accurately simulate what happens in the webclient: the field
+ // doesn't notify the BasicModel that it has changed directly, as it waits for the user
+ // to focusout or navigate (e.g. by pressing ENTER)
+ fieldDebounce: 5000,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'create') {
+ assert.step('create');
+ return prom.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should have 4 records at the beginning");
+
+ // add an element and press ENTER twice
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ var enterEvent = {
+ keyCode: $.ui.keyCode.ENTER,
+ which: $.ui.keyCode.ENTER,
+ };
+ await testUtils.fields.editAndTrigger(
+ kanban.$('.o_kanban_quick_create').find('input[name=foo]'),
+ 'new partner',
+ ['input', $.Event('keydown', enterEvent), $.Event('keydown', enterEvent)]
+ );
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should not have created the record yet");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner',
+ "quick create should not be empty yet");
+ assert.hasClass(kanban.$('.o_kanban_quick_create'), 'o_disabled',
+ "quick create should be disabled");
+
+ prom.resolve();
+ await nextTick();
+
+ assert.containsN(kanban, '.o_kanban_record', 5,
+ "should have created a new record");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '',
+ "quick create should now be empty");
+ assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled',
+ "quick create should be enabled");
+
+ assert.verifySteps(['create']);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: prevent multiple adds with Add clicked', async function (assert) {
+ assert.expect(9);
+
+ var prom = makeTestPromise();
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'create') {
+ assert.step('create');
+ return prom.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should have 4 records at the beginning");
+
+ // add an element and click 'Add' twice
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create').find('input[name=foo]'), 'new partner');
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create').find('.o_kanban_add'));
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create').find('.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should not have created the record yet");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner',
+ "quick create should not be empty yet");
+ assert.hasClass(kanban.$('.o_kanban_quick_create'),'o_disabled',
+ "quick create should be disabled");
+
+ prom.resolve();
+
+ await nextTick();
+ assert.containsN(kanban, '.o_kanban_record', 5,
+ "should have created a new record");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '',
+ "quick create should now be empty");
+ assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled',
+ "quick create should be enabled");
+
+ assert.verifySteps(['create']);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: prevent multiple adds with ENTER, with onchange', async function (assert) {
+ assert.expect(13);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.int_field += (obj.foo ? 3 : 0);
+ },
+ };
+ var shouldDelayOnchange = false;
+ var prom = makeTestPromise();
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ assert.step('onchange');
+ if (shouldDelayOnchange) {
+ return Promise.resolve(prom).then(function () {
+ return result;
+ });
+ }
+ }
+ if (args.method === 'create') {
+ assert.step('create');
+ assert.deepEqual(_.pick(args.args[0], 'foo', 'int_field'), {
+ foo: 'new partner',
+ int_field: 3,
+ });
+ }
+ return result;
+ },
+ // add a fieldDebounce to accurately simulate what happens in the webclient: the field
+ // doesn't notify the BasicModel that it has changed directly, as it waits for the user
+ // to focusout or navigate (e.g. by pressing ENTER)
+ fieldDebounce: 5000,
+ });
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should have 4 records at the beginning");
+
+ // add an element and press ENTER twice
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ shouldDelayOnchange = true;
+ var enterEvent = {
+ keyCode: $.ui.keyCode.ENTER,
+ which: $.ui.keyCode.ENTER,
+ };
+
+ await testUtils.fields.editAndTrigger(
+ kanban.$('.o_kanban_quick_create').find('input[name=foo]'),
+ 'new partner',
+ ['input', $.Event('keydown', enterEvent), $.Event('keydown', enterEvent)]
+ );
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should not have created the record yet");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner',
+ "quick create should not be empty yet");
+ assert.hasClass(kanban.$('.o_kanban_quick_create'),'o_disabled',
+ "quick create should be disabled");
+
+ prom.resolve();
+
+ await nextTick();
+ assert.containsN(kanban, '.o_kanban_record', 5,
+ "should have created a new record");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '',
+ "quick create should now be empty");
+ assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled',
+ "quick create should be enabled");
+
+ assert.verifySteps([
+ 'onchange', // default_get
+ 'onchange', // new partner
+ 'create',
+ 'onchange', // default_get
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: click Add to create, with delayed onchange', async function (assert) {
+ assert.expect(13);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.int_field += (obj.foo ? 3 : 0);
+ },
+ };
+ var shouldDelayOnchange = false;
+ var prom = makeTestPromise();
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/><field name="int_field"/></div>' +
+ '</t></templates></kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ assert.step('onchange');
+ if (shouldDelayOnchange) {
+ return Promise.resolve(prom).then(function () {
+ return result;
+ });
+ }
+ }
+ if (args.method === 'create') {
+ assert.step('create');
+ assert.deepEqual(_.pick(args.args[0], 'foo', 'int_field'), {
+ foo: 'new partner',
+ int_field: 3,
+ });
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should have 4 records at the beginning");
+
+ // add an element and click 'add'
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ shouldDelayOnchange = true;
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create').find('input[name=foo]'), 'new partner');
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create').find('.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_record', 4,
+ "should not have created the record yet");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), 'new partner',
+ "quick create should not be empty yet");
+ assert.hasClass(kanban.$('.o_kanban_quick_create'),'o_disabled',
+ "quick create should be disabled");
+
+ prom.resolve(); // the onchange returns
+
+ await nextTick();
+ assert.containsN(kanban, '.o_kanban_record', 5,
+ "should have created a new record");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input[name=foo]').val(), '',
+ "quick create should now be empty");
+ assert.doesNotHaveClass(kanban.$('.o_kanban_quick_create'), 'o_disabled',
+ "quick create should be enabled");
+
+ assert.verifySteps([
+ 'onchange', // default_get
+ 'onchange', // new partner
+ 'create',
+ 'onchange', // default_get
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create when first column is folded', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group:first'), 'o_column_folded',
+ "first column should not be folded");
+
+ // fold the first column
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold'));
+
+ assert.hasClass(kanban.$('.o_kanban_group:first'),'o_column_folded',
+ "first column should be folded");
+
+ // click on 'Create' to open the quick create in the first column
+ await testUtils.kanban.clickCreate(kanban);
+
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group:first'), 'o_column_folded',
+ "first column should no longer be folded");
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+ assert.strictEqual($quickCreate.length, 1,
+ "should have added a quick create element in first column");
+
+ // fold again the first column
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold'));
+
+ assert.hasClass(kanban.$('.o_kanban_group:first'),'o_column_folded',
+ "first column should be folded");
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "there should be no more quick create");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: cancel when not dirty', async function (assert) {
+ assert.expect(11);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain one record");
+
+ // click to add an element
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have open the quick create widget");
+
+ // click again to add an element -> should have kept the quick create open
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have kept the quick create open");
+
+ // click outside: should remove the quick create
+ await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first'));
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "the quick create should not have been destroyed");
+
+ // click to reopen the quick create
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have open the quick create widget");
+
+ // press ESC: should remove the quick create
+ kanban.$('.o_kanban_quick_create input').trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ESCAPE,
+ which: $.ui.keyCode.ESCAPE,
+ }));
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "quick create widget should have been removed");
+
+ // click to reopen the quick create
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have open the quick create widget");
+
+ // click on 'Discard': should remove the quick create
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first'));
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "the quick create should be destroyed when the user clicks outside");
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should still contain one record");
+
+ // click to reopen the quick create
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have open the quick create widget");
+
+ // clicking on the quick create itself should keep it open
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create'));
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should not have been destroyed when clicked on itself");
+
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: cancel when modal is opened', async function (assert) {
+ assert.expect(3);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="product_id"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ });
+
+ // click to add an element
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have open the quick create widget");
+
+ kanban.$('.o_kanban_quick_create input')
+ .val('test')
+ .trigger('keyup')
+ .trigger('focusout');
+ await nextTick();
+
+ // When focusing out of the many2one, a modal to add a 'product' will appear.
+ // The following assertions ensures that a click on the body element that has 'modal-open'
+ // will NOT close the quick create.
+ // This can happen when the user clicks out of the input because of a race condition between
+ // the focusout of the m2o and the global 'click' handler of the quick create.
+ // Check odoo/odoo#61981 for more details.
+ const $body = kanban.$el.closest('body');
+ assert.hasClass($body, 'modal-open',
+ "modal should be opening after m2o focusout");
+ await testUtils.dom.click($body);
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "quick create should stay open while modal is opening");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: cancel when dirty', async function (assert) {
+ assert.expect(7);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain one record");
+
+ // click to add an element and edit it
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have open the quick create widget");
+
+ var $quickCreate = kanban.$('.o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'some value');
+
+ // click outside: should not remove the quick create
+ await testUtils.dom.click(kanban.$('.o_kanban_group .o_kanban_record:first'));
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should not have been destroyed");
+
+ // press ESC: should remove the quick create
+ $quickCreate.find('input').trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ESCAPE,
+ which: $.ui.keyCode.ESCAPE,
+ }));
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "quick create widget should have been removed");
+
+ // click to reopen quick create and edit it
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have open the quick create widget");
+
+ $quickCreate = kanban.$('.o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'some value');
+
+ // click on 'Discard': should remove the quick create
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create .o_kanban_cancel'));
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "the quick create should be destroyed when the user clicks outside");
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should still contain one record");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record and edit in grouped mode', async function (assert) {
+ assert.expect(6);
+
+ var newRecordID;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ var def = this._super.apply(this, arguments);
+ if (args.method === 'name_create') {
+ def.then(function (result) {
+ newRecordID = result[0];
+ });
+ }
+ return def;
+ },
+ groupBy: ['bar'],
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.mode, "edit",
+ "should trigger 'open_record' event in edit mode");
+ assert.strictEqual(event.data.res_id, newRecordID,
+ "should open the correct record");
+ },
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain one record");
+
+ // click to add and edit an element
+ var $quickCreate = kanban.$('.o_kanban_quick_create');
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+ $quickCreate = kanban.$('.o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'new partner');
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_edit'));
+
+ assert.strictEqual(this.data.partner.records.length, 5,
+ "should have created a partner");
+ assert.strictEqual(_.last(this.data.partner.records).name, "new partner",
+ "should have correct name");
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should now contain two records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create several records in a row', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain one record");
+
+ // click to add an element, fill the input and press ENTER
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should be open");
+
+ await testUtils.kanban.quickCreate(kanban, 'new partner 1');
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should now contain two records");
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should still be open");
+
+ // create a second element in a row
+ await testUtils.kanban.quickCreate(kanban, 'new partner 2');
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3,
+ "first column should now contain three records");
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should still be open");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create is disabled until record is created and read', async function (assert) {
+ assert.expect(6);
+
+ var prom = makeTestPromise();
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'read') {
+ return prom.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain one record");
+
+ // click to add a record, and add two in a row (first one will be delayed)
+ await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
+
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "the quick create should be open");
+
+ await testUtils.kanban.quickCreate(kanban, 'new partner 1');
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should still contain one record");
+ assert.containsOnce(kanban, '.o_kanban_quick_create.o_disabled',
+ "quick create should be disabled");
+
+ prom.resolve();
+
+ await nextTick();
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should now contain two records");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create:not(.o_disabled)').length, 1,
+ "quick create should be enabled");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record fail in grouped by many2one', async function (assert) {
+ assert.expect(8);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,false,form': '<form string="Partner">' +
+ '<field name="product_id"/>' +
+ '<field name="foo"/>' +
+ '</form>',
+ },
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'name_create') {
+ return Promise.reject({
+ message: {
+ code: 200,
+ data: {},
+ message: "Odoo server error",
+ },
+ event: $.Event()
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "there should be 2 records in first column");
+
+ await testUtils.kanban.clickCreate(kanban); // Click on 'Create'
+ assert.hasClass(kanban.$('.o_kanban_group:first() > div:nth(1)'),'o_kanban_quick_create',
+ "clicking on create should open the quick_create in the first column");
+
+ await testUtils.kanban.quickCreate(kanban, 'test');
+
+ assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1,
+ "a form view dialog should have been opened (in edit)");
+ assert.strictEqual($('.modal .o_field_many2one input').val(), 'hello',
+ "the correct product_id should already be set");
+
+ // specify a name and save
+ await testUtils.fields.editInput($('.modal input[name=foo]'), 'test');
+ await testUtils.modal.clickButton('Save');
+
+ assert.strictEqual($('.modal').length, 0, "the modal should be closed");
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3,
+ "there should be 3 records in first column");
+ var $firstRecord = kanban.$('.o_kanban_group:first .o_kanban_record:first');
+ assert.strictEqual($firstRecord.text(), 'test',
+ "the first record of the first column should be the new one");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create:not(.o_disabled)').length, 1,
+ "quick create should be enabled");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record is re-enabled after discard on failure', async function (assert) {
+ assert.expect(4);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,false,form': '<form string="Partner">' +
+ '<field name="product_id"/>' +
+ '<field name="foo"/>' +
+ '</form>',
+ },
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'name_create') {
+ return Promise.reject({
+ message: {
+ code: 200,
+ data: {},
+ message: "Odoo server error",
+ },
+ event: $.Event()
+ });
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ await testUtils.kanban.clickCreate(kanban);
+ assert.containsOnce(kanban, '.o_kanban_quick_create',
+ "should have a quick create widget");
+
+ await testUtils.kanban.quickCreate(kanban, 'test');
+
+ assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1,
+ "a form view dialog should have been opened (in edit)");
+
+ await testUtils.modal.clickButton('Discard');
+
+ assert.strictEqual($('.modal').length, 0, "the modal should be closed");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create:not(.o_disabled)').length, 1,
+ "quick create widget should have been re-enabled");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record fails in grouped by char', async function (assert) {
+ assert.expect(7);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<field name="foo"/>' +
+ '</form>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_create') {
+ return Promise.reject({
+ message: {
+ code: 200,
+ data: {},
+ message: "Odoo server error",
+ },
+ event: $.Event()
+ });
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {foo: 'yop'},
+ "should write the correct value for foo");
+ assert.deepEqual(args.kwargs.context, {default_foo: 'yop', default_name: 'test'},
+ "should send the correct default value for foo");
+ }
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['foo'],
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "there should be 1 record in first column");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'test');
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1,
+ "a form view dialog should have been opened (in edit)");
+ assert.strictEqual($('.modal .o_field_widget[name=foo]').val(), 'yop',
+ "the correct default value for foo should already be set");
+ await testUtils.modal.clickButton('Save');
+
+ assert.strictEqual($('.modal').length, 0, "the modal should be closed");
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "there should be 2 records in first column");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record fails in grouped by selection', async function (assert) {
+ assert.expect(7);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="state"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<field name="state"/>' +
+ '</form>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_create') {
+ return Promise.reject({
+ message: {
+ code: 200,
+ data: {},
+ message: "Odoo server error",
+ },
+ event: $.Event()
+ });
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {state: 'abc'},
+ "should write the correct value for state");
+ assert.deepEqual(args.kwargs.context, {default_state: 'abc', default_name: 'test'},
+ "should send the correct default value for state");
+ }
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['state'],
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "there should be 1 record in first column");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'test');
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1,
+ "a form view dialog should have been opened (in edit)");
+ assert.strictEqual($('.modal .o_field_widget[name=state]').val(), '"abc"',
+ "the correct default value for state should already be set");
+
+ await testUtils.modal.clickButton('Save');
+
+ assert.strictEqual($('.modal').length, 0, "the modal should be closed");
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "there should be 2 records in first column");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in empty grouped kanban', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ var result = {
+ groups: [
+ {__domain: [['product_id', '=', 3]], product_id_count: 0},
+ {__domain: [['product_id', '=', 5]], product_id_count: 0},
+ ],
+ length: 2,
+ };
+ return Promise.resolve(result);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "there should be 2 columns");
+ assert.containsNone(kanban, '.o_kanban_record',
+ "both columns should be empty");
+
+ await testUtils.kanban.clickCreate(kanban);
+
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_quick_create',
+ "should have opened the quick create in the first column");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped on date(time) field', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['date'],
+ intercepts: {
+ switch_view: function (ev) {
+ assert.deepEqual(_.pick(ev.data, 'res_id', 'view_type'), {
+ res_id: undefined,
+ view_type: 'form',
+ }, "should trigger an event to open the form view (twice)");
+ },
+ },
+ });
+
+ assert.containsNone(kanban, '.o_kanban_header .o_kanban_quick_add i',
+ "quick create should be disabled when grouped on a date field");
+
+ // clicking on CREATE in control panel should not open a quick create
+ await testUtils.kanban.clickCreate(kanban);
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "should not have opened the quick create widget");
+
+ await kanban.reload({groupBy: ['datetime']});
+
+ assert.containsNone(kanban, '.o_kanban_header .o_kanban_quick_add i',
+ "quick create should be disabled when grouped on a datetime field");
+
+ // clicking on CREATE in control panel should not open a quick create
+ await testUtils.kanban.clickCreate(kanban);
+ assert.containsNone(kanban, '.o_kanban_quick_create',
+ "should not have opened the quick create widget");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record feature is properly enabled/disabled at reload', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['foo'],
+ });
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3,
+ "quick create should be enabled when grouped on a char field");
+
+ await kanban.reload({groupBy: ['date']});
+
+ assert.containsNone(kanban, '.o_kanban_header .o_kanban_quick_add i',
+ "quick create should now be disabled (grouped on date field)");
+
+ await kanban.reload({groupBy: ['bar']});
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 2,
+ "quick create should be enabled again (grouped on boolean field)");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped by char field', async function (assert) {
+ assert.expect(4);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['foo'],
+ mockRPC: function (route, args) {
+ if (args.method === 'name_create') {
+ assert.deepEqual(args.kwargs.context, {default_foo: 'yop'},
+ "should send the correct default value for foo");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3,
+ "quick create should be enabled when grouped on a char field");
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain 1 record");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record');
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should now contain 2 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped by boolean field', async function (assert) {
+ assert.expect(4);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (args.method === 'name_create') {
+ assert.deepEqual(args.kwargs.context, {default_bar: true},
+ "should send the correct default value for bar");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 2,
+ "quick create should be enabled when grouped on a boolean field");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 3,
+ "second column (true) should contain 3 records");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:nth(1) .o_kanban_quick_add i'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record');
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 4,
+ "second column (true) should now contain 4 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped on selection field', async function (assert) {
+ assert.expect(4);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_create') {
+ assert.deepEqual(args.kwargs.context, {default_state: 'abc'},
+ "should send the correct default value for bar");
+ }
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['state'],
+ });
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3,
+ "quick create should be enabled when grouped on a selection field");
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column (abc) should contain 1 record");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record');
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column (abc) should contain 2 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped by char field (within quick_create_view)', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="foo"/>' +
+ '</form>',
+ },
+ groupBy: ['foo'],
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {foo: 'yop'},
+ "should write the correct value for foo");
+ assert.deepEqual(args.kwargs.context, {default_foo: 'yop'},
+ "should send the correct default value for foo");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3,
+ "quick create should be enabled when grouped on a char field");
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column should contain 1 record");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i'));
+ assert.strictEqual(kanban.$('.o_kanban_quick_create input').val(), 'yop',
+ "should have set the correct foo value by default");
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column should now contain 2 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped by boolean field (within quick_create_view)', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="bar"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="bar"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {bar: true},
+ "should write the correct value for bar");
+ assert.deepEqual(args.kwargs.context, {default_bar: true},
+ "should send the correct default value for bar");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 2,
+ "quick create should be enabled when grouped on a boolean field");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 3,
+ "second column (true) should contain 3 records");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:nth(1) .o_kanban_quick_add i'));
+ assert.ok(kanban.$('.o_kanban_quick_create .o_field_boolean input').is(':checked'),
+ "should have set the correct bar value by default");
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 4,
+ "second column (true) should now contain 4 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record in grouped by selection field (within quick_create_view)', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="state"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="state"/>' +
+ '</form>',
+ },
+ groupBy: ['state'],
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {state: 'abc'},
+ "should write the correct value for state");
+ assert.deepEqual(args.kwargs.context, {default_state: 'abc'},
+ "should send the correct default value for state");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_header .o_kanban_quick_add i', 3,
+ "quick create should be enabled when grouped on a selection field");
+ assert.containsOnce(kanban, '.o_kanban_group:first .o_kanban_record',
+ "first column (abc) should contain 1 record");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_header:first .o_kanban_quick_add i'));
+ assert.strictEqual(kanban.$('.o_kanban_quick_create select').val(), '"abc"',
+ "should have set the correct state value by default");
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2,
+ "first column (abc) should now contain 2 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record while adding a new column', async function (assert) {
+ assert.expect(10);
+
+ var def = testUtils.makeTestPromise();
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'name_create' && args.model === 'product') {
+ return def.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 2);
+
+ // add a new column
+ assert.containsOnce(kanban, '.o_column_quick_create');
+ assert.isNotVisible(kanban.$('.o_column_quick_create input'));
+
+ await testUtils.dom.click(kanban.$('.o_quick_create_folded'));
+
+ assert.isVisible(kanban.$('.o_column_quick_create input'));
+
+ await testUtils.fields.editInput(kanban.$('.o_column_quick_create input'), 'new column');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group', 2);
+
+ // click to add a new record
+ await testUtils.dom.click(kanban.$buttons.find('.o-kanban-button-new'));
+
+ // should wait for the column to be created (and view to be re-rendered
+ // before opening the quick create
+ assert.containsNone(kanban, '.o_kanban_quick_create');
+
+ // unlock column creation
+ def.resolve();
+ await testUtils.nextTick();
+ assert.containsN(kanban, '.o_kanban_group', 3);
+ assert.containsOnce(kanban, '.o_kanban_quick_create');
+
+ // quick create record in first column
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'new record');
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create .o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group:first .o_kanban_record', 3);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('close a column while quick creating a record', async function (assert) {
+ assert.expect(6);
+
+ const def = testUtils.makeTestPromise();
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban on_create="quick_create" quick_create_view="some_view_ref">
+ <templates><t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t></templates>
+ </kanban>`,
+ archs: {
+ 'partner,some_view_ref,form': '<form><field name="int_field"/></form>',
+ },
+ groupBy: ['product_id'],
+ async mockRPC(route, args) {
+ const result = this._super(...arguments);
+ if (args.method === 'load_views') {
+ await def;
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.containsNone(kanban, '.o_column_folded');
+
+ // click to quick create a new record in the first column (this operation is delayed)
+ await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_quick_add'));
+
+ assert.containsNone(kanban, '.o_form_view');
+
+ // click to fold the first column
+ await testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold'));
+
+ assert.containsOnce(kanban, '.o_column_folded');
+
+ def.resolve();
+ await testUtils.nextTick();
+
+ assert.containsNone(kanban, '.o_form_view');
+ assert.containsOnce(kanban, '.o_column_folded');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create record: open on a column while another column has already one', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ // Click on quick create in first column
+ await testUtils.dom.click(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_quick_add'));
+ assert.containsOnce(kanban, '.o_kanban_quick_create');
+ assert.containsOnce(kanban.$('.o_kanban_group:nth-child(1)'), '.o_kanban_quick_create');
+
+ // Click on quick create in second column
+ await testUtils.dom.click(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_quick_add'));
+ assert.containsOnce(kanban, '.o_kanban_quick_create');
+ assert.containsOnce(kanban.$('.o_kanban_group:nth-child(2)'), '.o_kanban_quick_create');
+
+ // Click on quick create in first column once again
+ await testUtils.dom.click(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_quick_add'));
+ assert.containsOnce(kanban, '.o_kanban_quick_create');
+ assert.containsOnce(kanban.$('.o_kanban_group:nth-child(1)'), '.o_kanban_quick_create');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('many2many_tags in kanban views', async function (assert) {
+ assert.expect(12);
+
+ this.data.partner.records[0].category_ids = [6, 7];
+ this.data.partner.records[1].category_ids = [7, 8];
+ this.data.category.records.push({
+ id: 8,
+ name: "hello",
+ color: 0,
+ });
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<field name="category_ids" widget="many2many_tags" options="{\'color_field\': \'color\'}"/>' +
+ '<field name="foo"/>' +
+ '<field name="state" widget="priority"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ switch_view: function (event) {
+ assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), {
+ mode: 'readonly',
+ model: 'partner',
+ res_id: 1,
+ view_type: 'form',
+ }, "should trigger an event to open the clicked record in a form view");
+ },
+ },
+ });
+
+ var $first_record = kanban.$('.o_kanban_record:first()');
+ assert.strictEqual($first_record.find('.o_field_many2manytags .o_tag').length, 2,
+ 'first record should contain 2 tags');
+ assert.hasClass($first_record.find('.o_tag:first()'),'o_tag_color_2',
+ 'first tag should have color 2');
+ assert.verifySteps(['/web/dataset/search_read', '/web/dataset/call_kw/category/read'],
+ 'two RPC should have been done (one search read and one read for the m2m)');
+
+ // Checks that second records has only one tag as one should be hidden (color 0)
+ assert.strictEqual(kanban.$('.o_kanban_record').eq(1).find('.o_tag').length, 1,
+ 'there should be only one tag in second record');
+
+ // Write on the record using the priority widget to trigger a re-render in readonly
+ await testUtils.dom.click(kanban.$('.o_field_widget.o_priority a.o_priority_star.fa-star-o').first());
+ assert.verifySteps([
+ '/web/dataset/call_kw/partner/write',
+ '/web/dataset/call_kw/partner/read',
+ '/web/dataset/call_kw/category/read'
+ ], 'five RPCs should have been done (previous 2, 1 write (triggers a re-render), same 2 at re-render');
+ assert.strictEqual(kanban.$('.o_kanban_record:first()').find('.o_field_many2manytags .o_tag').length, 2,
+ 'first record should still contain only 2 tags');
+
+ // click on a tag (should trigger switch_view)
+ await testUtils.dom.click(kanban.$('.o_tag:contains(gold):first'));
+
+ kanban.destroy();
+ });
+
+ QUnit.test('Do not open record when clicking on `a` with `href`', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records = [
+ { id: 1, foo: 'yop' },
+ ];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<field name="foo"/>' +
+ '<div>' +
+ '<a class="o_test_link" href="#">test link</a>' +
+ '</div>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ intercepts: {
+ // when clicking on a record in kanban view,
+ // it switches to form view.
+ switch_view: function () {
+ throw new Error("should not switch view");
+ },
+ },
+ doNotDisableAHref: true,
+ });
+
+ var $record = kanban.$('.o_kanban_record:not(.o_kanban_ghost)');
+ assert.strictEqual($record.length, 1,
+ "should display a kanban record");
+
+ var $testLink = $record.find('a');
+ assert.strictEqual($testLink.length, 1,
+ "should contain a link in the kanban record");
+ assert.ok(!!$testLink[0].href,
+ "link inside kanban record should have non-empty href");
+
+ // Prevent the browser default behaviour when clicking on anything.
+ // This includes clicking on a `<a>` with `href`, so that it does not
+ // change the URL in the address bar.
+ // Note that we should not specify a click listener on 'a', otherwise
+ // it may influence the kanban record global click handler to not open
+ // the record.
+ $(document.body).on('click.o_test', function (ev) {
+ assert.notOk(ev.isDefaultPrevented(),
+ "should not prevented browser default behaviour beforehand");
+ assert.strictEqual(ev.target, $testLink[0],
+ "should have clicked on the test link in the kanban record");
+ ev.preventDefault();
+ });
+
+ await testUtils.dom.click($testLink);
+
+ $(document.body).off('click.o_test');
+ kanban.destroy();
+ });
+
+ QUnit.test('o2m loaded in only one batch', async function (assert) {
+ assert.expect(9);
+
+ this.data.subtask = {
+ fields: {
+ name: {string: 'Name', type: 'char'}
+ },
+ records: [
+ {id: 1, name: "subtask #1"},
+ {id: 2, name: "subtask #2"},
+ ]
+ };
+ this.data.partner.fields.subtask_ids = {
+ string: 'Subtasks',
+ type: 'one2many',
+ relation: 'subtask'
+ };
+ this.data.partner.records[0].subtask_ids = [1];
+ this.data.partner.records[1].subtask_ids = [2];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="subtask_ids" widget="many2many_tags"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await kanban.reload();
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ ]);
+ kanban.destroy();
+ });
+
+ QUnit.test('m2m loaded in only one batch', async function (assert) {
+ assert.expect(9);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="category_ids" widget="many2many_tags"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await kanban.reload(kanban);
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ ]);
+ kanban.destroy();
+ });
+
+ QUnit.test('fetch reference in only one batch', async function (assert) {
+ assert.expect(9);
+
+ this.data.partner.records[0].ref_product = 'product,3';
+ this.data.partner.records[1].ref_product = 'product,5';
+ this.data.partner.fields.ref_product = {
+ string: "Reference Field",
+ type: 'reference',
+ };
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<field name="ref_product"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await kanban.reload();
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'name_get',
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'name_get',
+ ]);
+ kanban.destroy();
+ });
+
+ QUnit.test('wait x2manys batch fetches to re-render', async function (assert) {
+ assert.expect(7);
+ var done = assert.async();
+
+ var def = Promise.resolve();
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="category_ids" widget="many2many_tags"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ var result = this._super(route, args);
+ if (args.method === 'read') {
+ return def.then(function() {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ def = testUtils.makeTestPromise();
+ assert.containsN(kanban, '.o_tag', 2);
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ kanban.update({groupBy: ['state']});
+ def.then(async function () {
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ await testUtils.nextTick();
+ assert.containsN(kanban, '.o_kanban_group', 3);
+
+ assert.containsN(kanban, '.o_tag', 2,
+ 'Should display 2 tags after update');
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_tag').text(),
+ 'gold', 'First category should be \'gold\'');
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(2) .o_tag').text(),
+ 'silver', 'Second category should be \'silver\'');
+ kanban.destroy();
+ done();
+ });
+ await testUtils.nextTick();
+ def.resolve();
+ });
+
+ QUnit.test('can drag and drop a record from one column to the next', async function (assert) {
+ assert.expect(9);
+
+ // @todo: remove this resequenceDef whenever the jquery upgrade branch
+ // is merged. This is currently necessary to simulate the reality: we
+ // need the click handlers to be executed after the end of the drag and
+ // drop operation, not before.
+ var resequenceDef = testUtils.makeTestPromise();
+
+ var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
+ this.data.partner.fields.sequence = {type: 'number', string: "Sequence"};
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click"><field name="foo"/>' +
+ '<t t-if="widget.editable"><span class="thisiseditable">edit</span></t>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.ok(true, "should call resequence");
+ return resequenceDef.then(_.constant(true));
+ }
+ return this._super(route, args);
+ },
+ });
+ assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2);
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2);
+ assert.containsN(kanban, '.thisiseditable', 4);
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ envIDs = [3, 2, 4, 1]; // first record of first column moved to the bottom of second column
+ await testUtils.dom.dragAndDrop($record, $group, {withTrailingClick: true});
+
+ resequenceDef.resolve();
+ await testUtils.nextTick();
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3);
+ assert.containsN(kanban, '.thisiseditable', 4);
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ resequenceDef.resolve();
+ kanban.destroy();
+ });
+
+ QUnit.test('drag and drop a record, grouped by selection', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div><field name="state"/></div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ groupBy: ['state'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.ok(true, "should call resequence");
+ return Promise.resolve(true);
+ }
+ if (args.model === 'partner' && args.method === 'write') {
+ assert.deepEqual(args.args[1], {state: 'def'});
+ }
+ return this._super(route, args);
+ },
+ });
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record');
+
+ var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ await testUtils.dom.dragAndDrop($record, $group);
+ await nextTick(); // wait for resequence after drag and drop
+
+ assert.containsNone(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2);
+ kanban.destroy();
+ });
+
+ QUnit.test('prevent drag and drop of record if grouped by readonly', async function (assert) {
+ assert.expect(12);
+
+ this.data.partner.fields.foo.readonly = true;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box"><div>' +
+ '<field name="foo"/>' +
+ '<field name="state" readonly="1"/>' +
+ '</div></t>' +
+ '</templates>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ return Promise.resolve();
+ }
+ if (args.model === 'partner' && args.method === 'write') {
+ throw new Error('should not be draggable');
+ }
+ return this._super(route, args);
+ },
+ });
+ // simulate an update coming from the searchview, with another groupby given
+ await kanban.update({groupBy: ['state']});
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record');
+
+ // drag&drop a record in another column
+ var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ await testUtils.dom.dragAndDrop($record, $group);
+ await nextTick(); // wait for resequence after drag and drop
+ // should not be draggable
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record');
+
+ // simulate an update coming from the searchview, with another groupby given
+ await kanban.update({groupBy: ['foo']});
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2);
+
+ // drag&drop a record in another column
+ $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ $group = kanban.$('.o_kanban_group:nth-child(2)');
+ await testUtils.dom.dragAndDrop($record, $group);
+ await nextTick(); // wait for resequence after drag and drop
+ // should not be draggable
+ assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2);
+
+ // drag&drop a record in the same column
+ var $record1 = kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(0)');
+ var $record2 = kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(1)');
+ assert.strictEqual($record1.text(), "blipDEF", "first record should be DEF");
+ assert.strictEqual($record2.text(), "blipGHI", "second record should be GHI");
+ await testUtils.dom.dragAndDrop($record2, $record1, {position: 'top'});
+ // should still be able to resequence
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(0)').text(), "blipGHI",
+ "records should have been resequenced");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record:eq(1)').text(), "blipDEF",
+ "records should have been resequenced");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('prevent drag and drop if grouped by date/datetime field', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[0].date = '2017-01-08';
+ this.data.partner.records[1].date = '2017-01-09';
+ this.data.partner.records[2].date = '2017-02-08';
+ this.data.partner.records[3].date = '2017-02-10';
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['date:month'],
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have 2 columns");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2,
+ "1st column should contain 2 records of January month");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length , 2,
+ "2nd column should contain 2 records of February month");
+
+ // drag&drop a record in another column
+ var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ await testUtils.dragAndDrop($record, $group);
+
+ // should not drag&drop record
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length , 2,
+ "Should remain same records in first column(2 records)");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length , 2,
+ "Should remain same records in 2nd column(2 record)");
+ kanban.destroy();
+ });
+
+ QUnit.test('completely prevent drag and drop if records_draggable set to false', async function (assert) {
+ assert.expect(6);
+
+ var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" records_draggable="false">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['product_id'],
+ });
+
+ // testing initial state
+ assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2);
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2);
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ // attempt to drag&drop a record in another column
+ var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ await testUtils.dom.dragAndDrop($record, $group, {withTrailingClick: true});
+
+ // should not drag&drop record
+ assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
+ "First column should still contain 2 records");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
+ "Second column should still contain 2 records");
+ assert.deepEqual(kanban.exportState().resIds, envIDs, "Records should not have moved");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('prevent drag and drop of record if onchange fails', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges = {
+ product_id: function (obj) {}
+ };
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box"><div>' +
+ '<field name="foo"/>' +
+ '<field name="product_id"/>' +
+ '</div></t>' +
+ '</templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/onchange') {
+ return Promise.reject({});
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2,
+ "column should contain 2 records");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 2,
+ "column should contain 2 records");
+ // drag&drop a record in another column
+ var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ await testUtils.dom.dragAndDrop($record, $group);
+ // should not be dropped, card should reset back to first column
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2,
+ "column should now contain 2 records");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 2,
+ "column should contain 2 records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('kanban view with default_group_by', async function (assert) {
+ assert.expect(7);
+ this.data.partner.records.product_id = 1;
+ this.data.product.records.push({id: 1, display_name: "third product"});
+
+ var readGroupCount = 0;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" default_group_by="bar">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/web_read_group') {
+ readGroupCount++;
+ var correctGroupBy;
+ if (readGroupCount === 2) {
+ correctGroupBy = ['product_id'];
+ } else {
+ correctGroupBy = ['bar'];
+ }
+ // this is done three times
+ assert.ok(_.isEqual(args.kwargs.groupby, correctGroupBy),
+ "groupby args should be correct");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
+ assert.containsN(kanban, '.o_kanban_group', 2, "should have " + 2 + " columns");
+
+ // simulate an update coming from the searchview, with another groupby given
+ await kanban.update({groupBy: ['product_id']});
+ assert.containsN(kanban, '.o_kanban_group', 2, "should now have " + 3 + " columns");
+
+ // simulate an update coming from the searchview, removing the previously set groupby
+ await kanban.update({groupBy: []});
+ assert.containsN(kanban, '.o_kanban_group', 2, "should have " + 2 + " columns again");
+ kanban.destroy();
+ });
+
+ QUnit.test('kanban view not groupable', async function (assert) {
+ assert.expect(3);
+
+ const searchMenuTypesOriginal = KanbanView.prototype.searchMenuTypes;
+ KanbanView.prototype.searchMenuTypes = ['filter', 'favorite'];
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban class="o_kanban_test" default_group_by="bar">
+ <field name="bar"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>
+ `,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter string="candle" name="itsName" context="{'group_by': 'foo'}"/>
+ </search>
+ `,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ throw new Error("Should not do a read_group RPC");
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: { search_default_itsName: 1, },
+ });
+
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
+ assert.containsNone(kanban, '.o_control_panel div.o_search_options div.o_group_by_menu');
+ assert.deepEqual(cpHelpers.getFacetTexts(kanban), []);
+
+ kanban.destroy();
+ KanbanView.prototype.searchMenuTypes = searchMenuTypesOriginal;
+ });
+
+ QUnit.test('kanban view with create=False', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" create="0">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ });
+
+ assert.ok(!kanban.$buttons || !kanban.$buttons.find('.o-kanban-button-new').length,
+ "Create button shouldn't be there");
+ kanban.destroy();
+ });
+
+ QUnit.test('clicking on a link triggers correct event', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><a type="edit">Edit</a></div>' +
+ '</t></templates></kanban>',
+ });
+
+ testUtils.mock.intercept(kanban, 'switch_view', function (event) {
+ assert.deepEqual(event.data, {
+ view_type: 'form',
+ res_id: 1,
+ mode: 'edit',
+ model: 'partner',
+ });
+ });
+ await testUtils.dom.click(kanban.$('a').first());
+ kanban.destroy();
+ });
+
+ QUnit.test('environment is updated when (un)folding groups', async function (assert) {
+ assert.expect(3);
+
+ var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ // fold the second group and check that the res_ids it contains are no
+ // longer in the environment
+ envIDs = [1, 3];
+ await testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_kanban_toggle_fold'));
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ // re-open the second group and check that the res_ids it contains are
+ // back in the environment
+ envIDs = [1, 3, 2, 4];
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last'));
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('create a column in grouped on m2o', async function (assert) {
+ assert.expect(14);
+
+ var nbRPCs = 0;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ nbRPCs++;
+ if (args.method === 'name_create') {
+ assert.ok(true, "should call name_create");
+ }
+ //Create column will call resequence to set column order
+ if (route === '/web/dataset/resequence') {
+ assert.ok(true, "should call resequence");
+ return Promise.resolve(true);
+ }
+ return this._super(route, args);
+ },
+ });
+ assert.containsOnce(kanban, '.o_column_quick_create', "should have a quick create column");
+ assert.notOk(kanban.$('.o_column_quick_create input').is(':visible'),
+ "the input should not be visible");
+
+ await testUtils.dom.click(kanban.$('.o_quick_create_folded'));
+
+ assert.ok(kanban.$('.o_column_quick_create input').is(':visible'),
+ "the input should be visible");
+
+ // discard the column creation and click it again
+ await kanban.$('.o_column_quick_create input').trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ESCAPE,
+ which: $.ui.keyCode.ESCAPE,
+ }));
+ assert.notOk(kanban.$('.o_column_quick_create input').is(':visible'),
+ "the input should not be visible after discard");
+
+ await testUtils.dom.click(kanban.$('.o_quick_create_folded'));
+ assert.ok(kanban.$('.o_column_quick_create input').is(':visible'),
+ "the input should be visible");
+
+ await kanban.$('.o_column_quick_create input').val('new value').trigger('input');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:last span:contains(new value)').length, 1,
+ "the last column should be the newly created one");
+ assert.ok(_.isNumber(kanban.$('.o_kanban_group:last').data('id')),
+ 'the created column should have the correct id');
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group:last'), 'o_column_folded',
+ 'the created column should not be folded');
+
+ // fold and unfold the created column, and check that no RPC is done (as there is no record)
+ nbRPCs = 0;
+ await testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_kanban_toggle_fold'));
+ assert.hasClass(kanban.$('.o_kanban_group:last'),'o_column_folded',
+ 'the created column should now be folded');
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last'));
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group:last'), 'o_column_folded');
+ assert.strictEqual(nbRPCs, 0, 'no rpc should have been done when folding/unfolding');
+
+ // quick create a record
+ await testUtils.kanban.clickCreate(kanban);
+ assert.hasClass(kanban.$('.o_kanban_group:first() > div:nth(1)'),'o_kanban_quick_create',
+ "clicking on create should open the quick_create in the first column");
+ kanban.destroy();
+ });
+
+ QUnit.test('auto fold group when reach the limit', async function (assert) {
+ assert.expect(9);
+
+ var data = this.data;
+ for (var i = 0; i < 12; i++) {
+ data.product.records.push({
+ id: (8 + i),
+ name: ("column"),
+ });
+ data.partner.records.push({
+ id: (20 + i),
+ foo: ("dumb entry"),
+ product_id: (8 + i),
+ });
+ }
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ return this._super.apply(this, arguments).then(function (result) {
+ result.groups[2].__fold = true;
+ result.groups[8].__fold = true;
+ return result;
+ });
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // we look if column are fold/unfold according what is expected
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group:nth-child(2)'), 'o_column_folded');
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group:nth-child(4)'), 'o_column_folded');
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group:nth-child(10)'), 'o_column_folded');
+ assert.hasClass(kanban.$('.o_kanban_group:nth-child(3)'), 'o_column_folded');
+ assert.hasClass(kanban.$('.o_kanban_group:nth-child(9)'), 'o_column_folded');
+
+ // we look if columns are actually fold after we reached the limit
+ assert.hasClass(kanban.$('.o_kanban_group:nth-child(13)'), 'o_column_folded');
+ assert.hasClass(kanban.$('.o_kanban_group:nth-child(14)'), 'o_column_folded');
+
+ // we look if we have the right count of folded/unfolded column
+ assert.containsN(kanban, '.o_kanban_group:not(.o_column_folded)', 10);
+ assert.containsN(kanban, '.o_kanban_group.o_column_folded', 4);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('hide and display help message (ESC) in kanban quick create', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ await testUtils.dom.click(kanban.$('.o_quick_create_folded'));
+ assert.ok(kanban.$('.o_discard_msg').is(':visible'),
+ 'the ESC to discard message is visible');
+
+ // click outside the column (to lose focus)
+ await testUtils.dom.clickFirst(kanban.$('.o_kanban_header'));
+ assert.notOk(kanban.$('.o_discard_msg').is(':visible'),
+ 'the ESC to discard message is no longer visible');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('delete a column in grouped on m2o', async function (assert) {
+ assert.expect(37);
+
+ testUtils.mock.patch(KanbanRenderer, {
+ _renderGrouped: function () {
+ this._super.apply(this, arguments);
+ // set delay and revert animation time to 0 so dummy drag and drop works
+ if (this.$el.sortable('instance')) {
+ this.$el.sortable('option', {delay: 0, revert: 0});
+ }
+ },
+ });
+
+ var resequencedIDs;
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ resequencedIDs = args.ids;
+ assert.strictEqual(_.reject(args.ids, _.isNumber).length, 0,
+ "column resequenced should be existing records with IDs");
+ return Promise.resolve(true);
+ }
+ if (args.method) {
+ assert.step(args.method);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // check the initial rendering
+ assert.containsN(kanban, '.o_kanban_group', 2, "should have two columns");
+ assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 3,
+ 'first column should be [3, "hello"]');
+ assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 5,
+ 'second column should be [5, "xmo"]');
+ assert.strictEqual(kanban.$('.o_kanban_group:last .o_column_title').text(), 'xmo',
+ 'second column should have correct title');
+ assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 2,
+ "second column should have two records");
+
+ // check available actions in kanban header's config dropdown
+ assert.ok(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').length,
+ "should be able to fold the column");
+ assert.ok(kanban.$('.o_kanban_group:first .o_column_edit').length,
+ "should be able to edit the column");
+ assert.ok(kanban.$('.o_kanban_group:first .o_column_delete').length,
+ "should be able to delete the column");
+ assert.ok(!kanban.$('.o_kanban_group:first .o_column_archive_records').length, "should not be able to archive all the records");
+ assert.ok(!kanban.$('.o_kanban_group:first .o_column_unarchive_records').length, "should not be able to restore all the records");
+
+ // delete second column (first cancel the confirm request, then confirm)
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_delete'));
+ assert.ok($('.modal').length, 'a confirm modal should be displayed');
+ await testUtils.modal.clickButton('Cancel'); // click on cancel
+ assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 5,
+ 'column [5, "xmo"] should still be there');
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_delete'));
+ assert.ok($('.modal').length, 'a confirm modal should be displayed');
+ await testUtils.modal.clickButton('Ok'); // click on confirm
+ assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 3,
+ 'last column should now be [3, "hello"]');
+ assert.containsN(kanban, '.o_kanban_group', 2, "should still have two columns");
+ assert.ok(!_.isNumber(kanban.$('.o_kanban_group:first').data('id')),
+ 'first column should have no id (Undefined column)');
+ // check available actions on 'Undefined' column
+ assert.ok(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').length,
+ "should be able to fold the column");
+ assert.ok(!kanban.$('.o_kanban_group:first .o_column_delete').length,
+ 'Undefined column could not be deleted');
+ assert.ok(!kanban.$('.o_kanban_group:first .o_column_edit').length,
+ 'Undefined column could not be edited');
+ assert.ok(!kanban.$('.o_kanban_group:first .o_column_archive_records').length, "Records of undefined column could not be archived");
+ assert.ok(!kanban.$('.o_kanban_group:first .o_column_unarchive_records').length, "Records of undefined column could not be restored");
+ assert.verifySteps(['web_read_group', 'unlink', 'web_read_group']);
+ assert.strictEqual(kanban.renderer.widgets.length, 2,
+ "the old widgets should have been correctly deleted");
+
+ // test column drag and drop having an 'Undefined' column
+ await testUtils.dom.dragAndDrop(
+ kanban.$('.o_column_title:first'),
+ kanban.$('.o_column_title:last'), {position: 'right'}
+ );
+ assert.strictEqual(resequencedIDs, undefined,
+ "resequencing require at least 2 not Undefined columns");
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded'));
+ kanban.$('.o_column_quick_create input').val('once third column');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+ var newColumnID = kanban.$('.o_kanban_group:last').data('id');
+ await testUtils.dom.dragAndDrop(
+ kanban.$('.o_column_title:first'),
+ kanban.$('.o_column_title:last'), {position: 'right'}
+ );
+ assert.deepEqual([3, newColumnID], resequencedIDs,
+ "moving the Undefined column should not affect order of other columns");
+ await testUtils.dom.dragAndDrop(
+ kanban.$('.o_column_title:first'),
+ kanban.$('.o_column_title:nth(1)'), {position: 'right'}
+ );
+ await nextTick(); // wait for resequence after drag and drop
+ assert.deepEqual([newColumnID, 3], resequencedIDs,
+ "moved column should be resequenced accordingly");
+ assert.verifySteps(['name_create', 'read', 'read', 'read']);
+
+ kanban.destroy();
+ testUtils.mock.unpatch(KanbanRenderer);
+ });
+
+ QUnit.test('create a column, delete it and create another one', async function (assert) {
+ assert.expect(5);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2, "should have two columns");
+
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded'));
+ kanban.$('.o_column_quick_create input').val('new column 1');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group', 3, "should have two columns");
+
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_delete'));
+ await testUtils.modal.clickButton('Ok');
+
+ assert.containsN(kanban, '.o_kanban_group', 2, "should have twos columns");
+
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded'));
+ kanban.$('.o_column_quick_create input').val('new column 2');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+
+ assert.containsN(kanban, '.o_kanban_group', 3, "should have three columns");
+ assert.strictEqual(kanban.$('.o_kanban_group:last span:contains(new column 2)').length, 1,
+ "the last column should be the newly created one");
+ kanban.destroy();
+ });
+
+ QUnit.test('edit a column in grouped on m2o', async function (assert) {
+ assert.expect(12);
+
+ var nbRPCs = 0;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ archs: {
+ 'product,false,form': '<form string="Product"><field name="display_name"/></form>',
+ },
+ mockRPC: function (route, args) {
+ nbRPCs++;
+ return this._super(route, args);
+ },
+ });
+ assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'xmo',
+ 'title of the column should be "xmo"');
+
+ // edit the title of column [5, 'xmo'] and close without saving
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit'));
+ assert.containsOnce(document.body, '.modal .o_form_editable',
+ "a form view should be open in a modal");
+ assert.strictEqual($('.modal .o_form_editable input').val(), 'xmo',
+ 'the name should be "xmo"');
+ await testUtils.fields.editInput($('.modal .o_form_editable input'), 'ged'); // change the value
+ nbRPCs = 0;
+ await testUtils.dom.click($('.modal-header .close'));
+ assert.containsNone(document.body, '.modal');
+ assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'xmo',
+ 'title of the column should still be "xmo"');
+ assert.strictEqual(nbRPCs, 0, 'no RPC should have been done');
+
+ // edit the title of column [5, 'xmo'] and discard
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit'));
+ await testUtils.fields.editInput($('.modal .o_form_editable input'), 'ged'); // change the value
+ nbRPCs = 0;
+ await testUtils.modal.clickButton('Discard');
+ assert.containsNone(document.body, '.modal');
+ assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'xmo',
+ 'title of the column should still be "xmo"');
+ assert.strictEqual(nbRPCs, 0, 'no RPC should have been done');
+
+ // edit the title of column [5, 'xmo'] and save
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit'));
+ await testUtils.fields.editInput($('.modal .o_form_editable input'), 'ged'); // change the value
+ nbRPCs = 0;
+ await testUtils.modal.clickButton('Save'); // click on save
+ assert.ok(!$('.modal').length, 'the modal should be closed');
+ assert.strictEqual(kanban.$('.o_kanban_group[data-id=5] .o_column_title').text(), 'ged',
+ 'title of the column should be "ged"');
+ assert.strictEqual(nbRPCs, 4, 'should have done 1 write, 1 read_group and 2 search_read');
+ kanban.destroy();
+ });
+
+ QUnit.test('edit a column propagates right context', async function (assert) {
+ assert.expect(4);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ archs: {
+ 'product,false,form': '<form string="Product"><field name="display_name"/></form>',
+ },
+ session: {user_context: {lang: 'brol'}},
+ mockRPC: function (route, args) {
+ let context;
+ if (route === '/web/dataset/search_read' && args.model === 'partner') {
+ context = args.context;
+ assert.strictEqual(context.lang, 'brol',
+ 'lang is present in context for partner operations');
+ }
+ if (args.model === 'product') {
+ context = args.kwargs.context;
+ assert.strictEqual(context.lang, 'brol',
+ 'lang is present in context for product operations');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group[data-id=5]'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group[data-id=5] .o_column_edit'));
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create column should be opened if there is no column', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ domain: [['foo', '=', 'norecord']],
+ });
+
+ assert.containsNone(kanban, '.o_kanban_group');
+ assert.containsOnce(kanban, '.o_column_quick_create');
+ assert.ok(kanban.$('.o_column_quick_create input').is(':visible'),
+ "the quick create should be opened");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create several columns in a row', async function (assert) {
+ assert.expect(10);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "should have two columns");
+ assert.containsOnce(kanban, '.o_column_quick_create',
+ "should have a ColumnQuickCreate widget");
+ assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_folded:visible',
+ "the ColumnQuickCreate should be folded");
+ assert.containsNone(kanban, '.o_column_quick_create .o_quick_create_unfolded:visible',
+ "the ColumnQuickCreate should be folded");
+
+ // add a new column
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded'));
+ assert.containsNone(kanban, '.o_column_quick_create .o_quick_create_folded:visible',
+ "the ColumnQuickCreate should be unfolded");
+ assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded:visible',
+ "the ColumnQuickCreate should be unfolded");
+ kanban.$('.o_column_quick_create input').val('New Column 1');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .btn-primary'));
+ assert.containsN(kanban, '.o_kanban_group', 3,
+ "should now have three columns");
+
+ // add another column
+ assert.containsNone(kanban, '.o_column_quick_create .o_quick_create_folded:visible',
+ "the ColumnQuickCreate should still be unfolded");
+ assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded:visible',
+ "the ColumnQuickCreate should still be unfolded");
+ kanban.$('.o_column_quick_create input').val('New Column 2');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .btn-primary'));
+ assert.containsN(kanban, '.o_kanban_group', 4,
+ "should now have four columns");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create column and examples', async function (assert) {
+ assert.expect(12);
+
+ kanbanExamplesRegistry.add('test', {
+ examples:[{
+ name: "A first example",
+ columns: ["Column 1", "Column 2", "Column 3"],
+ description: "Some description",
+ }, {
+ name: "A second example",
+ columns: ["Col 1", "Col 2"],
+ }],
+ });
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban examples="test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.containsOnce(kanban, '.o_column_quick_create',
+ "should have a ColumnQuickCreate widget");
+
+ // open the quick create
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .o_quick_create_folded'));
+
+ assert.containsOnce(kanban, '.o_column_quick_create .o_kanban_examples:visible',
+ "should have a link to see examples");
+
+ // click to see the examples
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .o_kanban_examples'));
+
+ assert.strictEqual($('.modal .o_kanban_examples_dialog').length, 1,
+ "should have open the examples dialog");
+ assert.strictEqual($('.modal .o_kanban_examples_dialog_nav li').length, 2,
+ "should have two examples (in the menu)");
+ assert.strictEqual($('.modal .o_kanban_examples_dialog_nav a').text(),
+ ' A first example A second example ', "example names should be correct");
+ assert.strictEqual($('.modal .o_kanban_examples_dialog_content .tab-pane').length, 2,
+ "should have two examples");
+
+ var $firstPane = $('.modal .o_kanban_examples_dialog_content .tab-pane:first');
+ assert.strictEqual($firstPane.find('.o_kanban_examples_group').length, 3,
+ "there should be 3 stages in the first example");
+ assert.strictEqual($firstPane.find('h6').text(), 'Column 1Column 2Column 3',
+ "column titles should be correct");
+ assert.strictEqual($firstPane.find('.o_kanban_examples_description').text().trim(),
+ "Some description", "the correct description should be displayed");
+
+ var $secondPane = $('.modal .o_kanban_examples_dialog_content .tab-pane:nth(1)');
+ assert.strictEqual($secondPane.find('.o_kanban_examples_group').length, 2,
+ "there should be 2 stages in the second example");
+ assert.strictEqual($secondPane.find('h6').text(), 'Col 1Col 2',
+ "column titles should be correct");
+ assert.strictEqual($secondPane.find('.o_kanban_examples_description').text().trim(),
+ "", "there should be no description for the second example");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create column and examples background with ghostColumns titles', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records = [];
+
+ kanbanExamplesRegistry.add('test', {
+ ghostColumns: ["Ghost 1", "Ghost 2", "Ghost 3", "Ghost 4"],
+ examples:[{
+ name: "A first example",
+ columns: ["Column 1", "Column 2", "Column 3"],
+ description: "Some description",
+ }, {
+ name: "A second example",
+ columns: ["Col 1", "Col 2"],
+ }],
+ });
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban examples="test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_example_background',
+ "should have ExamplesBackground when no data");
+ assert.strictEqual(kanban.$('.o_kanban_examples_group h6').text(), 'Ghost 1Ghost 2Ghost 3Ghost 4',
+ "ghost title should be correct");
+ assert.containsOnce(kanban, '.o_column_quick_create',
+ "should have a ColumnQuickCreate widget");
+ assert.containsOnce(kanban, '.o_column_quick_create .o_kanban_examples:visible',
+ "should not have a link to see examples as there is no examples registered");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick create column and examples background without ghostColumns titles', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.containsOnce(kanban, '.o_kanban_example_background',
+ "should have ExamplesBackground when no data");
+ assert.strictEqual(kanban.$('.o_kanban_examples_group h6').text(), 'Column 1Column 2Column 3Column 4',
+ "ghost title should be correct");
+ assert.containsOnce(kanban, '.o_column_quick_create',
+ "should have a ColumnQuickCreate widget");
+ assert.containsNone(kanban, '.o_column_quick_create .o_kanban_examples:visible',
+ "should not have a link to see examples as there is no examples registered");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('nocontent helper after adding a record (kanban with progressbar)', async function (assert) {
+ assert.expect(3);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `<kanban >
+ <field name="product_id"/>
+ <progressbar field="foo" colors='{"yop": "success", "gnap": "warning", "blip": "danger"}' sum_field="int_field"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ domain: [['foo', '=', 'abcd']],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ const result = {
+ groups: [
+ { __domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello'] },
+ ],
+ };
+ return Promise.resolve(result);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_view_nocontent', "the nocontent helper is displayed");
+
+ // add a record
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle');
+ await testUtils.dom.click(kanban.$('button.o_kanban_add'));
+
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "the nocontent helper is not displayed after quick create");
+
+ // cancel quick create
+ await testUtils.dom.click(kanban.$('button.o_kanban_cancel'));
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "the nocontent helper is not displayed after cancelling the quick create");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('if view was not grouped at start, it can be grouped and ungrouped', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ });
+
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
+ await kanban.update({groupBy: ['product_id']});
+ assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped');
+ await kanban.update({groupBy: []});
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('no content helper when archive all records in kanban group', async function (assert) {
+ assert.expect(3);
+
+ // add active field on partner model to have archive option
+ this.data.partner.fields.active = {string: 'Active', type: 'boolean', default: true};
+ // remove last records to have only one column
+ this.data.partner.records = this.data.partner.records.slice(0, 3);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `<kanban class="o_kanban_test">
+ <field name="active"/>
+ <field name="bar"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/action_archive') {
+ const partnerIDS = args.args[0];
+ const records = this.data.partner.records;
+ _.each(partnerIDS, function (partnerID) {
+ _.find(records, function (record) {
+ return record.id === partnerID;
+ }).active = false;
+ });
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // check that the (unique) column contains 3 records
+ assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3);
+
+ // archive the records of the last column
+ testUtils.kanban.toggleGroupSettings($(kanban.el.querySelector('.o_kanban_group'))); // we should change the helper
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group .o_column_archive_records'));
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.modal.clickButton('Ok');
+ // check no content helper is exist
+ assert.containsOnce(kanban, '.o_view_nocontent');
+ kanban.destroy();
+ });
+
+ QUnit.test('no content helper when no data', async function (assert) {
+ assert.expect(3);
+
+ var records = this.data.partner.records;
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<t t-esc="record.foo.value"/>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates></kanban>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_view_nocontent',
+ "should display the no content helper");
+
+ assert.strictEqual(kanban.$('.o_view_nocontent p.hello:contains(add a partner)').length, 1,
+ "should have rendered no content helper from action");
+
+ this.data.partner.records = records;
+ await kanban.reload();
+
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "should not display the no content helper");
+ kanban.destroy();
+ });
+
+ QUnit.test('no nocontent helper for grouped kanban with empty groups', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ return this._super.apply(this, arguments).then(function (result) {
+ _.each(result.groups, function (group) {
+ group[args.kwargs.groupby[0] + '_count'] = 0;
+ });
+ return result;
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "there should be two columns");
+ assert.containsNone(kanban, '.o_kanban_record',
+ "there should be no records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('no nocontent helper for grouped kanban with no records', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsNone(kanban, '.o_kanban_group',
+ "there should be no columns");
+ assert.containsNone(kanban, '.o_kanban_record',
+ "there should be no records");
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "there should be no nocontent helper (we are in 'column creation mode')");
+ assert.containsOnce(kanban, '.o_column_quick_create',
+ "there should be a column quick create");
+ kanban.destroy();
+ });
+
+ QUnit.test('no nocontent helper is shown when no longer creating column', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "there should be no nocontent helper (we are in 'column creation mode')");
+
+ // creating a new column
+ kanban.$('.o_column_quick_create .o_input').val('applejack');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create .o_kanban_add'));
+
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "there should be no nocontent helper (still in 'column creation mode')");
+
+ // leaving column creation mode
+ kanban.$('.o_column_quick_create .o_input').trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ESCAPE,
+ which: $.ui.keyCode.ESCAPE,
+ }));
+
+ assert.containsOnce(kanban, '.o_view_nocontent',
+ "there should be a nocontent helper");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('no nocontent helper is hidden when quick creating a column', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ var result = {
+ groups: [
+ {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
+ ],
+ length: 1,
+ };
+ return Promise.resolve(result);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_view_nocontent',
+ "there should be a nocontent helper");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_add_column'));
+
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "there should be no nocontent helper (we are in 'column creation mode')");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('remove nocontent helper after adding a record', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ var result = {
+ groups: [
+ {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
+ ],
+ length: 1,
+ };
+ return Promise.resolve(result);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_view_nocontent',
+ "there should be a nocontent helper");
+
+ // add a record
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle');
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add'));
+
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "there should be no nocontent helper (there is now one record)");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('remove nocontent helper when adding a record', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ var result = {
+ groups: [
+ {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
+ ],
+ length: 1,
+ };
+ return Promise.resolve(result);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_view_nocontent',
+ "there should be a nocontent helper");
+
+ // add a record
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle');
+
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "there should be no nocontent helper (there is now one record)");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('nocontent helper is displayed again after canceling quick create', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="name"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ var result = {
+ groups: [
+ {__domain: [['product_id', '=', 3]], product_id_count: 0, product_id: [3, 'hello']},
+ ],
+ length: 1,
+ };
+ return Promise.resolve(result);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ // add a record
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add'));
+
+ await testUtils.dom.click(kanban.$('.o_kanban_view'));
+
+ assert.containsOnce(kanban, '.o_view_nocontent',
+ "there should be again a nocontent helper");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('nocontent helper for grouped kanban with no records with no group_create', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban group_create="false">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsNone(kanban, '.o_kanban_group',
+ "there should be no columns");
+ assert.containsNone(kanban, '.o_kanban_record',
+ "there should be no records");
+ assert.containsNone(kanban, '.o_view_nocontent',
+ "there should not be a nocontent helper");
+ assert.containsNone(kanban, '.o_column_quick_create',
+ "there should not be a column quick create");
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data and no columns', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records = [];
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsNone(kanban, '.o_view_nocontent');
+ assert.containsOnce(kanban, '.o_quick_create_unfolded');
+ assert.containsOnce(kanban, '.o_kanban_example_background_container');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data and click quick create', async function (assert) {
+ assert.expect(11);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "there should be two columns");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+ assert.containsN(kanban, '.o_kanban_record', 16,
+ "there should be 8 sample records by column");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record');
+ assert.containsNone(kanban, '.o_view_nocontent');
+ assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
+
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle');
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add'));
+
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_record');
+ assert.containsNone(kanban, '.o_view_nocontent');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data and cancel quick create', async function (assert) {
+ assert.expect(12);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "there should be two columns");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+ assert.containsN(kanban, '.o_kanban_record', 16,
+ "there should be 8 sample records by column");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record');
+ assert.containsNone(kanban, '.o_view_nocontent');
+ assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
+
+ await testUtils.dom.click(kanban.$('.o_kanban_view'));
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_quick_create');
+ assert.containsNone(kanban, '.o_kanban_record');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: keyboard navigation', async function (assert) {
+ assert.expect(5);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ <field name="state" widget="priority"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ result.groups.forEach(g => g.product_id_count = 0);
+ }
+ return result;
+ },
+ });
+
+ // Check keynav is disabled
+ assert.hasClass(
+ kanban.el.querySelector('.o_kanban_record'),
+ 'o_sample_data_disabled'
+ );
+ assert.hasClass(
+ kanban.el.querySelector('.o_kanban_toggle_fold'),
+ 'o_sample_data_disabled'
+ );
+ assert.containsNone(kanban.renderer, '[tabindex]:not([tabindex="-1"])');
+
+ assert.hasClass(document.activeElement, 'o_searchview_input');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'down');
+
+ assert.hasClass(document.activeElement, 'o_searchview_input');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty kanban with sample data', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records = [];
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 10,
+ "there should be 10 sample records");
+ assert.containsOnce(kanban, '.o_view_nocontent');
+
+ await kanban.reload({ domain: [['id', '<', 0]]});
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data and many2many_tags', async function (assert) {
+ assert.expect(6);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="int_field"/>
+ <field name="category_ids" widget="many2many_tags"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ assert.step(method || route);
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2, "there should be 2 'real' columns");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.ok(kanban.$('.o_kanban_record').length >= 1, "there should be sample records");
+ assert.ok(kanban.$('.o_field_many2manytags .o_tag').length >= 1, "there should be tags");
+
+ assert.verifySteps(["web_read_group"], "should not read the tags");
+ kanban.destroy();
+ });
+
+ QUnit.test('sample data does not change after reload with sample data', async function (assert) {
+ assert.expect(4);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="int_field"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ });
+
+ const columns = kanban.el.querySelectorAll('.o_kanban_group');
+
+ assert.ok(columns.length >= 1, "there should be at least 1 sample column");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_record', 16);
+
+ const kanbanText = kanban.el.innerText;
+ await kanban.reload();
+
+ assert.strictEqual(kanbanText, kanban.el.innerText,
+ "the content should be the same after reloading the view");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('non empty kanban with sample data', async function (assert) {
+ assert.expect(5);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsNone(kanban, '.o_view_nocontent');
+
+ await kanban.reload({ domain: [['id', '<', 0]]});
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: add a column', async function (assert) {
+ assert.expect(6);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ result.groups = this.data.product.records.map(r => {
+ return {
+ product_id: [r.id, r.display_name],
+ product_id_count: 0,
+ __domain: ['product_id', '=', r.id],
+ };
+ });
+ result.length = result.groups.length;
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
+ await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
+ await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 3);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: cannot fold a column', async function (assert) {
+ // folding a column in grouped kanban with sample data is disabled, for the sake of simplicity
+ assert.expect(5);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return a single, empty group
+ result.groups = result.groups.slice(0, 1);
+ result.groups[0][`${kwargs.groupby[0]}_count`] = 0;
+ result.length = 1;
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
+
+ assert.hasClass(kanban.el.querySelector('.o_kanban_config .o_kanban_toggle_fold'), 'o_sample_data_disabled');
+ assert.hasClass(kanban.el.querySelector('.o_kanban_config .o_kanban_toggle_fold'), 'disabled');
+
+ kanban.destroy();
+ });
+
+ QUnit.skip('empty grouped kanban with sample data: fold/unfold a column', async function (assert) {
+ // folding/unfolding of grouped kanban with sample data is currently disabled
+ assert.expect(8);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return a single, empty group
+ result.groups = result.groups.slice(0, 1);
+ result.groups[0][`${kwargs.groupby[0]}_count`] = 0;
+ result.length = 1;
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // Fold the column
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
+ await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_kanban_toggle_fold'));
+
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.hasClass(kanban.$('.o_kanban_group'), 'o_column_folded');
+
+ // Unfold the column
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group.o_column_folded'));
+
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group'), 'o_column_folded');
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: delete a column', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records = [];
+
+ let groups = [{
+ product_id: [1, 'New'],
+ product_id_count: 0,
+ __domain: [],
+ }];
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { method }) {
+ let result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return a single, empty group
+ return {
+ groups,
+ length: groups.length,
+ };
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // Delete the first column
+ groups = [];
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
+ await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_column_delete'));
+ await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
+
+ assert.containsNone(kanban, '.o_kanban_group');
+ assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: add a column and delete it right away', async function (assert) {
+ assert.expect(9);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ result.groups = this.data.product.records.map(r => {
+ return {
+ product_id: [r.id, r.display_name],
+ product_id_count: 0,
+ __domain: ['product_id', '=', r.id],
+ };
+ });
+ result.length = result.groups.length;
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // add a new column
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
+ await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
+ await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 3);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // delete the column we just created
+ const newColumn = kanban.el.querySelectorAll('.o_kanban_group')[2];
+ await testUtils.dom.click(newColumn.querySelector('.o_kanban_config > a'));
+ await testUtils.dom.click(newColumn.querySelector('.dropdown-item.o_column_delete'));
+ await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('bounce create button when no data and click on empty area', async function (assert) {
+ assert.expect(2);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `<kanban class="o_kanban_test"><templates><t t-name="kanban-box">
+ <div>
+ <t t-esc="record.foo.value"/>
+ <field name="foo"/>
+ </div>
+ </t></templates></kanban>`,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ });
+
+ await testUtils.dom.click(kanban.$('.o_kanban_view'));
+ assert.doesNotHaveClass(kanban.$('.o-kanban-button-new'), 'o_catch_attention');
+
+ await kanban.reload({ domain: [['id', '<', 0]] });
+
+ await testUtils.dom.click(kanban.$('.o_kanban_view'));
+ assert.hasClass(kanban.$('.o-kanban-button-new'), 'o_catch_attention');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('buttons with modifiers', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[1].bar = false; // so that test is more complete
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: "partner",
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="state"/>' +
+ '<templates><div t-name="kanban-box">' +
+ '<button class="o_btn_test_1" type="object" name="a1" ' +
+ 'attrs="{\'invisible\': [[\'foo\', \'!=\', \'yop\']]}"/>' +
+ '<button class="o_btn_test_2" type="object" name="a2" ' +
+ 'attrs="{\'invisible\': [[\'bar\', \'=\', True]]}" ' +
+ 'states="abc,def"/>' +
+ '</div></templates>' +
+ '</kanban>',
+ });
+
+ assert.containsOnce(kanban, ".o_btn_test_1",
+ "kanban should have one buttons of type 1");
+ assert.containsN(kanban, ".o_btn_test_2", 3,
+ "kanban should have three buttons of type 2");
+ kanban.destroy();
+ });
+
+ QUnit.test('button executes action and reloads', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: "partner",
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<templates><div t-name="kanban-box">' +
+ '<field name="foo"/>' +
+ '<button type="object" name="a1" />' +
+ '</div></templates>' +
+ '</kanban>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(kanban.$('button[data-name="a1"]').length,
+ "kanban should have at least one button a1");
+
+ var count = 0;
+ testUtils.mock.intercept(kanban, 'execute_action', function (event) {
+ count++;
+ event.data.on_closed();
+ });
+ await testUtils.dom.click($('button[data-name="a1"]').first());
+ assert.strictEqual(count, 1, "should have triggered a execute action");
+
+ await testUtils.dom.click($('button[data-name="a1"]').first());
+ assert.strictEqual(count, 1, "double-click on kanban actions should be debounced");
+
+ assert.verifySteps([
+ '/web/dataset/search_read',
+ '/web/dataset/call_kw/partner/read'
+ ], 'a read should be done after the call button to reload the record');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('button executes action and check domain', async function (assert) {
+ assert.expect(2);
+
+ var data = this.data;
+ data.partner.fields.active = {string: "Active", type: "boolean", default: true};
+ for (var k in this.data.partner.records) {
+ data.partner.records[k].active = true;
+ }
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: "partner",
+ data: data,
+ arch:
+ '<kanban>' +
+ '<templates><div t-name="kanban-box">' +
+ '<field name="foo"/>' +
+ '<field name="active"/>' +
+ '<button type="object" name="a1" />' +
+ '<button type="object" name="toggle_active" />' +
+ '</div></templates>' +
+ '</kanban>',
+ });
+
+ testUtils.mock.intercept(kanban, 'execute_action', function (event) {
+ data.partner.records[0].active = false;
+ event.data.on_closed();
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_record:contains(yop)').length, 1, "should display 'yop' record");
+ await testUtils.dom.click(kanban.$('.o_kanban_record:contains(yop) button[data-name="toggle_active"]'));
+ assert.strictEqual(kanban.$('.o_kanban_record:contains(yop)').length, 0, "should remove 'yop' record from the view");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('button executes action with domain field not in view', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: "partner",
+ data: this.data,
+ domain: [['bar', '=', true]],
+ arch:
+ '<kanban>' +
+ '<templates><div t-name="kanban-box">' +
+ '<field name="foo"/>' +
+ '<button type="object" name="a1" />' +
+ '<button type="object" name="toggle_action" />' +
+ '</div></templates>' +
+ '</kanban>',
+ });
+
+ testUtils.mock.intercept(kanban, 'execute_action', function (event) {
+ event.data.on_closed();
+ });
+
+ try {
+ await testUtils.dom.click(kanban.$('.o_kanban_record:contains(yop) button[data-name="toggle_action"]'));
+ assert.strictEqual(true, true, 'Everything went fine');
+ } catch (e) {
+ assert.strictEqual(true, false, 'Error triggered at action execution');
+ }
+ kanban.destroy();
+ });
+
+ QUnit.test('rendering date and datetime', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].date = "2017-01-25";
+ this.data.partner.records[1].datetime= "2016-12-12 10:55:05";
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<t t-esc="record.date.raw_value"/>' +
+ '<t t-esc="record.datetime.raw_value"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ });
+
+ // FIXME: this test is locale dependant. we need to do it right.
+ assert.strictEqual(kanban.$('div.o_kanban_record:contains(Wed Jan 25)').length, 1,
+ "should have formatted the date");
+ assert.strictEqual(kanban.$('div.o_kanban_record:contains(Mon Dec 12)').length, 1,
+ "should have formatted the datetime");
+ kanban.destroy();
+ });
+
+ QUnit.test('evaluate conditions on relational fields', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].product_id = false;
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<field name="category_ids"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<button t-if="!record.product_id.raw_value" class="btn_a">A</button>' +
+ '<button t-if="!record.category_ids.raw_value.length" class="btn_b">B</button>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ });
+
+ assert.strictEqual($('.o_kanban_record:not(.o_kanban_ghost)').length, 4,
+ "there should be 4 records");
+ assert.strictEqual($('.o_kanban_record:not(.o_kanban_ghost) .btn_a').length, 1,
+ "only 1 of them should have the 'Action' button");
+ assert.strictEqual($('.o_kanban_record:not(.o_kanban_ghost) .btn_b').length, 2,
+ "only 2 of them should have the 'Action' button");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('resequence columns in grouped by m2o', async function (assert) {
+ assert.expect(6);
+ this.data.product.fields.sequence = {string: "Sequence", type: "integer"};
+
+ var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'),'ui-sortable',
+ "columns should be sortable");
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "should have two columns");
+ assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 3,
+ "first column should be id 3 before resequencing");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ // there is a 100ms delay on the d&d feature (jquery sortable) for
+ // kanban columns, making it hard to test. So we rather bypass the d&d
+ // for this test, and directly call the event handler
+ envIDs = [2, 4, 1, 3]; // the columns will be inverted
+ kanban._onResequenceColumn({data: {ids: [5, 3]}});
+ await nextTick(); // wait for resequencing before re-rendering
+ await kanban.update({}, {reload: false}); // re-render without reloading
+
+ assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 5,
+ "first column should be id 5 after resequencing");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('properly evaluate more complex domains', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="category_ids"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '<button type="object" attrs="{\'invisible\':[\'|\', (\'bar\',\'=\',True), (\'category_ids\', \'!=\', [])]}" class="btn btn-primary float-right" name="channel_join_and_get_info">Join</button>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ });
+
+ assert.containsOnce(kanban, 'button.oe_kanban_action_button',
+ "only one button should be visible");
+ kanban.destroy();
+ });
+
+ QUnit.test('edit the kanban color with the colorpicker', async function (assert) {
+ assert.expect(5);
+
+ var writeOnColor;
+
+ this.data.category.records[0].color = 12;
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'category',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="color"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div color="color">' +
+ '<div class="o_dropdown_kanban dropdown">' +
+ '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' +
+ '<span class="fa fa-bars fa-lg"/>' +
+ '</a>' +
+ '<ul class="dropdown-menu" role="menu">' +
+ '<li>' +
+ '<ul class="oe_kanban_colorpicker"/>' +
+ '</li>' +
+ '</ul>' +
+ '</div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write' && 'color' in args.args[1]) {
+ writeOnColor = true;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var $firstRecord = kanban.$('.o_kanban_record:first()');
+
+ assert.containsNone(kanban, '.o_kanban_record.oe_kanban_color_12',
+ "no record should have the color 12");
+ assert.strictEqual($firstRecord.find('.oe_kanban_colorpicker').length, 1,
+ "there should be a color picker");
+ assert.strictEqual($firstRecord.find('.oe_kanban_colorpicker').children().length, 12,
+ "the color picker should have 12 children (the colors)");
+
+ // Set a color
+ testUtils.kanban.toggleRecordDropdown($firstRecord);
+ await testUtils.dom.click($firstRecord.find('.oe_kanban_colorpicker a.oe_kanban_color_9'));
+ assert.ok(writeOnColor, "should write on the color field");
+ $firstRecord = kanban.$('.o_kanban_record:first()'); // First record is reloaded here
+ assert.ok($firstRecord.is('.oe_kanban_color_9'),
+ "the first record should have the color 9");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('load more records in column', async function (assert) {
+ assert.expect(13);
+
+ var envIDs = [1, 2, 4]; // the ids that should be in the environment during this test
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ viewOptions: {
+ limit: 2,
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(args.limit + ' - ' + args.offset);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2,
+ "there should be 2 records in the column");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ // load more
+ envIDs = [1, 2, 3, 4]; // id 3 will be loaded
+ await testUtils.dom.click(kanban.$('.o_kanban_group:eq(1)').find('.o_kanban_load_more'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3,
+ "there should now be 3 records in the column");
+ assert.verifySteps(['2 - undefined', '2 - undefined', '2 - 2'],
+ "the records should be correctly fetched");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
+ // reload
+ await kanban.reload();
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3,
+ "there should still be 3 records in the column after reload");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+ assert.verifySteps(['4 - undefined', '2 - undefined']);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('load more records in column with x2many', async function (assert) {
+ assert.expect(10);
+
+ this.data.partner.records[0].category_ids = [7];
+ this.data.partner.records[1].category_ids = [];
+ this.data.partner.records[2].category_ids = [6];
+ this.data.partner.records[3].category_ids = [];
+
+ // record [2] will be loaded after
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="category_ids"/>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ viewOptions: {
+ limit: 2,
+ },
+ mockRPC: function (route, args) {
+ if (args.model === 'category' && args.method === 'read') {
+ assert.step(String(args.args[0]));
+ }
+ if (route === '/web/dataset/search_read') {
+ if (args.limit) {
+ assert.strictEqual(args.limit, 2,
+ "the limit should be correctly set");
+ }
+ if (args.offset) {
+ assert.strictEqual(args.offset, 2,
+ "the offset should be correctly set at load more");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2,
+ "there should be 2 records in the column");
+
+ assert.verifySteps(['7'], "only the appearing category should be fetched");
+
+ // load more
+ await testUtils.dom.click(kanban.$('.o_kanban_group:eq(1)').find('.o_kanban_load_more'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3,
+ "there should now be 3 records in the column");
+
+ assert.verifySteps(['6'], "the other categories should not be fetched");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('update buttons after column creation', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['product_id'],
+ });
+
+ assert.isNotVisible(kanban.$buttons.find('.o-kanban-button-new'),
+ "Create button should be hidden");
+
+ await testUtils.dom.click(kanban.$('.o_column_quick_create'));
+ kanban.$('.o_column_quick_create input').val('new column');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+ assert.isVisible(kanban.$buttons.find('.o-kanban-button-new'),
+ "Create button should now be visible");
+ kanban.destroy();
+ });
+
+ QUnit.test('group_by_tooltip option when grouping on a many2one', async function (assert) {
+ assert.expect(12);
+ delete this.data.partner.records[3].product_id;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban default_group_by="bar">' +
+ '<field name="bar"/>' +
+ '<field name="product_id" '+
+ 'options=\'{"group_by_tooltip": {"name": "Kikou"}}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/product/read') {
+ assert.strictEqual(args.args[0].length, 2,
+ "read on two groups");
+ assert.deepEqual(args.args[1], ['display_name', 'name'],
+ "should read on specified fields on the group by relation");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped',
+ "should have classname 'o_kanban_grouped'");
+ assert.containsN(kanban, '.o_kanban_group', 2, "should have " + 2 + " columns");
+
+ // simulate an update coming from the searchview, with another groupby given
+ await kanban.update({groupBy: ['product_id']});
+ assert.containsN(kanban, '.o_kanban_group', 3, "should have " + 3 + " columns");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 1,
+ "column should contain 1 record(s)");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 2,
+ "column should contain 2 record(s)");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(3) .o_kanban_record').length, 1,
+ "column should contain 1 record(s)");
+ assert.ok(kanban.$('.o_kanban_group:first span.o_column_title:contains(Undefined)').length,
+ "first column should have a default title for when no value is provided");
+ assert.ok(!kanban.$('.o_kanban_group:first .o_kanban_header_title').data('original-title'),
+ "tooltip of first column should not defined, since group_by_tooltip title and the many2one field has no value");
+ assert.ok(kanban.$('.o_kanban_group:eq(1) span.o_column_title:contains(hello)').length,
+ "second column should have a title with a value from the many2one");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_header_title').data('original-title'),
+ "<div>Kikou</br>hello</div>",
+ "second column should have a tooltip with the group_by_tooltip title and many2one field value");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('move a record then put it again in the same column', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records = [];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['product_id'],
+ });
+
+ await testUtils.dom.click(kanban.$('.o_column_quick_create'));
+ kanban.$('.o_column_quick_create input').val('column1');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+
+ await testUtils.dom.click(kanban.$('.o_column_quick_create'));
+ kanban.$('.o_column_quick_create input').val('column2');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+
+ await testUtils.dom.click(kanban.$('.o_kanban_group:eq(1) .o_kanban_quick_add i'));
+ var $quickCreate = kanban.$('.o_kanban_group:eq(1) .o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'new partner');
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 0,
+ "column should contain 0 record");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 1,
+ "column should contain 1 records");
+
+ var $record = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(0)');
+ var $group = kanban.$('.o_kanban_group:eq(0)');
+ await testUtils.dom.dragAndDrop($record, $group);
+ await nextTick(); // wait for resequencing after drag and drop
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 1,
+ "column should contain 1 records");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 0,
+ "column should contain 0 records");
+
+ $record = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)');
+ $group = kanban.$('.o_kanban_group:eq(1)');
+
+ await testUtils.dom.dragAndDrop($record, $group);
+ await nextTick(); // wait for resequencing after drag and drop
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 0,
+ "column should contain 0 records");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 1,
+ "column should contain 1 records");
+ kanban.destroy();
+ });
+
+ QUnit.test('resequence a record twice', async function (assert) {
+ assert.expect(10);
+
+ this.data.partner.records = [];
+
+ var nbResequence = 0;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route) {
+ if (route === '/web/dataset/resequence') {
+ nbResequence++;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(kanban.$('.o_column_quick_create'));
+ kanban.$('.o_column_quick_create input').val('column1');
+ await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
+
+ await testUtils.dom.click(kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_add i'));
+ var $quickCreate = kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'record1');
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ await testUtils.dom.click(kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_add i'));
+ $quickCreate = kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('input'), 'record2');
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2,
+ "column should contain 2 records");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record2",
+ "records should be correctly ordered");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record1",
+ "records should be correctly ordered");
+
+ var $record1 = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)');
+ var $record2 = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)');
+ await testUtils.dom.dragAndDrop($record1, $record2, {position: 'top'});
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2,
+ "column should contain 2 records");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record1",
+ "records should be correctly ordered");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record2",
+ "records should be correctly ordered");
+
+ await testUtils.dom.dragAndDrop($record2, $record1, {position: 'top'});
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2,
+ "column should contain 2 records");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record2",
+ "records should be correctly ordered");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record1",
+ "records should be correctly ordered");
+ assert.strictEqual(nbResequence, 2, "should have resequenced twice");
+ kanban.destroy();
+ });
+
+ QUnit.test('basic support for widgets', async function (assert) {
+ assert.expect(1);
+
+ var MyWidget = Widget.extend({
+ init: function (parent, dataPoint) {
+ this.data = dataPoint.data;
+ },
+ start: function () {
+ this.$el.text(JSON.stringify(this.data));
+ },
+ });
+ widgetRegistry.add('test', MyWidget);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<t t-esc="record.foo.value"/>' +
+ '<field name="foo" blip="1"/>' +
+ '<widget name="test"/>' +
+ '</div>' +
+ '</t></templates></kanban>',
+ });
+
+ assert.strictEqual(kanban.$('.o_widget:eq(2)').text(), '{"foo":"gnap","id":3}',
+ "widget should have been instantiated");
+
+ kanban.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('subwidgets with on_attach_callback when changing record color', async function (assert) {
+ assert.expect(3);
+
+ var counter = 0;
+ var MyTestWidget = AbstractField.extend({
+ on_attach_callback: function () {
+ counter++;
+ },
+ });
+ fieldRegistry.add('test_widget', MyTestWidget);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'category',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="color"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div color="color">' +
+ '<div class="o_dropdown_kanban dropdown">' +
+ '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' +
+ '<span class="fa fa-bars fa-lg"/>' +
+ '</a>' +
+ '<ul class="dropdown-menu" role="menu">' +
+ '<li>' +
+ '<ul class="oe_kanban_colorpicker"/>' +
+ '</li>' +
+ '</ul>' +
+ '</div>' +
+ '<field name="name" widget="test_widget"/>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ });
+
+ // counter should be 2 as there are 2 records
+ assert.strictEqual(counter, 2, "on_attach_callback should have been called twice");
+
+ // set a color to kanban record
+ var $firstRecord = kanban.$('.o_kanban_record:first()');
+ testUtils.kanban.toggleRecordDropdown($firstRecord);
+ await testUtils.dom.click($firstRecord.find('.oe_kanban_colorpicker a.oe_kanban_color_9'));
+
+ // first record has replaced its $el with a new one
+ $firstRecord = kanban.$('.o_kanban_record:first()');
+ assert.hasClass($firstRecord, 'oe_kanban_color_9');
+ assert.strictEqual(counter, 3, "on_attach_callback method should be called 3 times");
+
+ delete fieldRegistry.map.test_widget;
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars properly work', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.containsN(kanban, '.o_kanban_counter', this.data.product.records.length,
+ "kanban counters should have been created");
+
+ assert.strictEqual(parseInt(kanban.$('.o_kanban_counter_side').last().text()), 36,
+ "counter should display the sum of int_field values");
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars: "false" bar is clickable', async function (assert) {
+ assert.expect(8);
+
+ this.data.partner.records.push({id: 5, bar: true, foo: false, product_id: 5, state: "ghi"});
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "4");
+ assert.containsN(kanban, '.o_kanban_counter_progress:last .progress-bar', 4);
+ assert.containsOnce(kanban, '.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]',
+ "should have false kanban color");
+ assert.hasClass(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'), 'bg-muted-full');
+
+ await testUtils.dom.click(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'));
+
+ assert.hasClass(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'), 'progress-bar-animated');
+ assert.hasClass(kanban.$('.o_kanban_group:last'), 'o_kanban_group_show_muted');
+ assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "1");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars: "false" bar with sum_field', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records.push({id: 5, bar: true, foo: false, int_field: 15, product_id: 5, state: "ghi"});
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<field name="foo"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "51");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'));
+
+ assert.hasClass(kanban.$('.o_kanban_counter_progress:last .progress-bar[data-filter="__false"]'), 'progress-bar-animated');
+ assert.strictEqual(kanban.$('.o_kanban_counter:last .o_kanban_counter_side').text(), "15");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars should not crash in non grouped views', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ assert.step(route);
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_record').text(), 'namenamenamename',
+ "should have renderer 4 records");
+
+ assert.verifySteps(['/web/dataset/search_read'], "no read on progress bar data is done");
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars: creating a new column should create a new progressbar', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="product_id"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ });
+
+ var nbProgressBars = kanban.$('.o_kanban_counter').length;
+
+ // Create a new column: this should create an empty progressbar
+ var $columnQuickCreate = kanban.$('.o_column_quick_create');
+ await testUtils.dom.click($columnQuickCreate.find('.o_quick_create_folded'));
+ $columnQuickCreate.find('input').val('test');
+ await testUtils.dom.click($columnQuickCreate.find('.btn-primary'));
+
+ assert.containsN(kanban, '.o_kanban_counter', nbProgressBars + 1,
+ "a new column with a new column progressbar should have been created");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars on quick create properly update counter', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ });
+
+ var initialCount = parseInt(kanban.$('.o_kanban_counter_side:first').text());
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create input'), 'Test');
+ await testUtils.dom.click(kanban.$('.o_kanban_add'));
+ var lastCount = parseInt(kanban.$('.o_kanban_counter_side:first').text());
+ await nextTick(); // await update
+ await nextTick(); // await read
+ assert.strictEqual(lastCount, initialCount + 1,
+ "kanban counters should have updated on quick create");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars are working with load more', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ domain: [['bar', '=', true]],
+ arch:
+ '<kanban limit="1">' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="id"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ });
+
+ // we have 1 record shown, load 2 more and check it worked
+ await testUtils.dom.click(kanban.$('.o_kanban_group').find('.o_kanban_load_more'));
+ await testUtils.dom.click(kanban.$('.o_kanban_group').find('.o_kanban_load_more'));
+ var shownIDs = _.map(kanban.$('.o_kanban_record'), function(record) {
+ return parseInt(record.innerText);
+ });
+ assert.deepEqual(shownIDs, [1, 2, 3], "intended records are loaded");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars on archiving records update counter', async function (assert) {
+ assert.expect(4);
+
+ // add active field on partner model and make all records active
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="active"/>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/action_archive') {
+ var partnerIDS = args.args[0];
+ var records = this.data.partner.records;
+ _.each(partnerIDS, function(partnerID) {
+ _.find(records, function (record) {
+ return record.id === partnerID;
+ }).active = false;
+ });
+ this.data.partner.records[0].active;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "36",
+ "counter should contain the correct value");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "1 yop",
+ "the counter progressbars should be correctly displayed");
+
+ // archive all records of the second columns
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:eq(1)'));
+ await testUtils.dom.click(kanban.$('.o_column_archive_records:visible'));
+ await testUtils.dom.click($('.modal-footer button:first'));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "0",
+ "counter should contain the correct value");
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "0 yop",
+ "the counter progressbars should have been correctly updated");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('kanban with progressbars: correctly update env when archiving records', async function (assert) {
+ assert.expect(2);
+
+ // add active field on partner model and make all records active
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="active"/>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/action_archive') {
+ var partnerIDS = args.args[0];
+ var records = this.data.partner.records
+ _.each(partnerIDS, function(partnerID) {
+ _.find(records, function (record) {
+ return record.id === partnerID;
+ }).active = false;
+ })
+ this.data.partner.records[0].active;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.deepEqual(kanban.exportState().resIds, [1, 2, 3, 4]);
+
+ // archive all records of the first column
+ testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first'));
+ await testUtils.dom.click(kanban.$('.o_column_archive_records:visible'));
+ await testUtils.dom.click($('.modal-footer button:first'));
+
+ assert.deepEqual(kanban.exportState().resIds, [1, 2, 3]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('RPCs when (re)loading kanban view progressbars', async function (assert) {
+ assert.expect(9);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await kanban.reload();
+
+ assert.verifySteps([
+ // initial load
+ 'web_read_group',
+ 'read_progress_bar',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ // reload
+ 'web_read_group',
+ 'read_progress_bar',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('drag & drop records grouped by m2o with progressbar', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].product_id = false;
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="int_field"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ return Promise.resolve(true);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "1",
+ "counter should contain the correct value");
+
+ await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'), kanban.$('.o_kanban_group:eq(1)'));
+ await nextTick(); // wait for update resulting from drag and drop
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "0",
+ "counter should contain the correct value");
+
+ await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(2)'), kanban.$('.o_kanban_group:eq(0)'));
+ await nextTick(); // wait for update resulting from drag and drop
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "1",
+ "counter should contain the correct value");
+
+ await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'), kanban.$('.o_kanban_group:eq(1)'));
+ await nextTick(); // wait for update resulting from drag and drop
+ assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "0",
+ "counter should contain the correct value");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('progress bar subgroup count recompute', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['bar'],
+ });
+
+ var $secondGroup = kanban.$('.o_kanban_group:eq(1)');
+ var initialCount = parseInt($secondGroup.find('.o_kanban_counter_side').text());
+ assert.strictEqual(initialCount, 3,
+ "Initial count should be Three");
+ await testUtils.dom.click($secondGroup.find('.bg-success-full'));
+ var lastCount = parseInt($secondGroup.find('.o_kanban_counter_side').text());
+ assert.strictEqual(lastCount, 1,
+ "kanban counters should vary according to what subgroup is selected");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('column progressbars on quick create with quick_create_view are updated', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' +
+ '<field name="int_field"/>' +
+ '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ archs: {
+ 'partner,some_view_ref,form': '<form>' +
+ '<field name="int_field"/>' +
+ '</form>',
+ },
+ groupBy: ['bar'],
+ });
+
+ var initialCount = parseInt(kanban.$('.o_kanban_counter_side:first').text());
+
+ await testUtils.kanban.clickCreate(kanban);
+ // fill the quick create and validate
+ var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
+ await testUtils.fields.editInput($quickCreate.find('.o_field_widget[name=int_field]'), '44');
+ await testUtils.dom.click($quickCreate.find('button.o_kanban_add'));
+
+ var lastCount = parseInt(kanban.$('.o_kanban_counter_side:first').text());
+ assert.strictEqual(lastCount, initialCount + 44,
+ "kanban counters should have been updated on quick create");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('keep adding quickcreate in first column after a record from this column was moved', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<kanban on_create="quick_create">' +
+ '<field name="int_field"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['foo'],
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ return Promise.resolve(true);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var $quickCreateGroup;
+ var $groups;
+ await _quickCreateAndTest();
+ await testUtils.dom.dragAndDrop($groups.first().find('.o_kanban_record:first'), $groups.eq(1));
+ await _quickCreateAndTest();
+ kanban.destroy();
+
+ async function _quickCreateAndTest() {
+ await testUtils.kanban.clickCreate(kanban);
+ $quickCreateGroup = kanban.$('.o_kanban_quick_create').closest('.o_kanban_group');
+ $groups = kanban.$('.o_kanban_group');
+ assert.strictEqual($quickCreateGroup[0], $groups[0],
+ "quick create should have been added in the first column");
+ }
+ });
+
+ QUnit.test('test displaying image (URL, image field not set)', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="id"/>' +
+ '<templates><t t-name="kanban-box"><div>' +
+ '<img t-att-src="kanban_image(\'partner\', \'image\', record.id.raw_value)"/>' +
+ '</div></t></templates>' +
+ '</kanban>',
+ });
+
+ // since the field image is not set, kanban_image will generate an URL
+ var imageOnRecord = kanban.$('img[data-src*="/web/image"][data-src*="&id=1"]');
+ assert.strictEqual(imageOnRecord.length, 1, "partner with image display image by url");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('test displaying image (binary & placeholder)', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="id"/>' +
+ '<field name="image"/>' +
+ '<templates><t t-name="kanban-box"><div>' +
+ '<img t-att-src="kanban_image(\'partner\', \'image\', record.id.raw_value)"/>' +
+ '</div></t></templates>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (route === 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==') {
+ assert.ok("The view's image should have been fetched.");
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ var images = kanban.el.querySelectorAll('img');
+ var placeholders = [];
+ for (var [index, img] of images.entries()) {
+ if (img.dataset.src.indexOf(this.data.partner.records[index].image) === -1) {
+ // Then we display a placeholder
+ placeholders.push(img);
+ }
+ }
+
+ assert.strictEqual(placeholders.length, this.data.partner.records.length - 1,
+ "partner with no image should display the placeholder");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('test displaying image (for another record)', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="id"/>' +
+ '<field name="image"/>' +
+ '<templates><t t-name="kanban-box"><div>' +
+ '<img t-att-src="kanban_image(\'partner\', \'image\', 1)"/>' +
+ '</div></t></templates>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (route === 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==') {
+ assert.ok("The view's image should have been fetched.");
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // the field image is set, but we request the image for a specific id
+ // -> for the record matching the ID, the base64 should be returned
+ // -> for all the other records, the image should be displayed by url
+ var imageOnRecord = kanban.$('img[data-src*="/web/image"][data-src*="&id=1"]');
+ assert.strictEqual(imageOnRecord.length, this.data.partner.records.length - 1,
+ "display image by url when requested for another record");
+
+ kanban.destroy();
+ });
+
+ QUnit.test("test displaying image from m2o field (m2o field not set)", async function (assert) {
+ assert.expect(2);
+ this.data.foo_partner = {
+ fields: {
+ name: {string: "Foo Name", type: "char"},
+ partner_id: {string: "Partner", type: "many2one", relation: "partner"},
+ },
+ records: [
+ {id: 1, name: 'foo_with_partner_image', partner_id: 1},
+ {id: 2, name: 'foo_no_partner'},
+ ]
+ };
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: "foo_partner",
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="name"/>
+ <field name="partner_id"/>
+ <img t-att-src="kanban_image('partner', 'image', record.partner_id.raw_value)"/>
+ </div>
+ </templates>
+ </kanban>`,
+ });
+
+ assert.containsOnce(kanban, 'img[data-src*="/web/image"][data-src$="&id=1"]', "image url should contain id of set partner_id");
+ assert.containsOnce(kanban, 'img[data-src*="/web/image"][data-src$="&id="]', "image url should contain an empty id if partner_id is not set");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('check if the view destroys all widgets and instances', async function (assert) {
+ assert.expect(2);
+
+ var instanceNumber = 0;
+ testUtils.mock.patch(mixins.ParentedMixin, {
+ init: function () {
+ instanceNumber++;
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ if (!this.isDestroyed()) {
+ instanceNumber--;
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ var params = {
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="product_id"/>' +
+ '<field name="category_ids"/>' +
+ '<field name="state"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ };
+
+ var kanban = await createView(params);
+ assert.ok(instanceNumber > 0);
+
+ kanban.destroy();
+ assert.strictEqual(instanceNumber, 0);
+
+ testUtils.mock.unpatch(mixins.ParentedMixin);
+ });
+
+ QUnit.test('grouped kanban becomes ungrouped when clearing domain then clearing groupby', async function (assert) {
+ // in this test, we simulate that clearing the domain is slow, so that
+ // clearing the groupby does not corrupt the data handled while
+ // reloading the kanban view.
+ assert.expect(4);
+
+ var prom = makeTestPromise();
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ domain: [['foo', '=', 'norecord']],
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ var result = this._super(route, args);
+ if (args.method === 'web_read_group') {
+ var isFirstUpdate = _.isEmpty(args.kwargs.domain) &&
+ args.kwargs.groupby &&
+ args.kwargs.groupby[0] === 'bar';
+ if (isFirstUpdate) {
+ return prom.then(function () {
+ return result;
+ });
+ }
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped',
+ "the kanban view should be grouped");
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_ungrouped',
+ "the kanban view should not be ungrouped");
+
+ kanban.update({domain: []}); // 1st update on kanban view
+ kanban.update({groupBy: false}); // 2n update on kanban view
+ prom.resolve(); // simulate slow 1st update of kanban view
+
+ await nextTick();
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped',
+ "the kanban view should not longer be grouped");
+ assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_ungrouped',
+ "the kanban view should have become ungrouped");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('quick_create on grouped kanban without column', async function (assert) {
+ assert.expect(1);
+ this.data.partner.records = [];
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ // force group_create to false, otherwise the CREATE button in control panel is hidden
+ arch: '<kanban class="o_kanban_test" group_create="0" on_create="quick_create"><templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '</div>' +
+ '</t></templates></kanban>',
+ groupBy: ['product_id'],
+
+ intercepts: {
+ switch_view: function (event) {
+ assert.ok(true, "switch_view was called instead of quick_create");
+ },
+ },
+ });
+ await testUtils.kanban.clickCreate(kanban);
+ kanban.destroy();
+ });
+
+ QUnit.test('keyboard navigation on kanban basic rendering', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<t t-esc="record.foo.value"/>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates></kanban>',
+ });
+
+ var $fisrtCard = kanban.$('.o_kanban_record:first');
+ var $secondCard = kanban.$('.o_kanban_record:eq(1)');
+
+ $fisrtCard.focus();
+ assert.strictEqual(document.activeElement, $fisrtCard[0], "the kanban cards are focussable");
+
+ $fisrtCard.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT, }));
+ assert.strictEqual(document.activeElement, $secondCard[0], "the second card should be focussed");
+
+ $secondCard.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT, keyCode: $.ui.keyCode.LEFT, }));
+ assert.strictEqual(document.activeElement, $fisrtCard[0], "the first card should be focussed");
+ kanban.destroy();
+ });
+
+ QUnit.test('keyboard navigation on kanban grouped rendering', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['bar'],
+ });
+
+ var $firstColumnFisrtCard = kanban.$('.o_kanban_record:first');
+ var $secondColumnFirstCard = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:first');
+ var $secondColumnSecondCard = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(1)');
+
+ $firstColumnFisrtCard.focus();
+
+ //RIGHT should select the next column
+ $firstColumnFisrtCard.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT, }));
+ assert.strictEqual(document.activeElement, $secondColumnFirstCard[0], "RIGHT should select the first card of the next column");
+
+ //DOWN should move up one card
+ $secondColumnFirstCard.trigger($.Event('keydown', { which: $.ui.keyCode.DOWN, keyCode: $.ui.keyCode.DOWN, }));
+ assert.strictEqual(document.activeElement, $secondColumnSecondCard[0], "DOWN should select the second card of the current column");
+
+ //LEFT should go back to the first column
+ $secondColumnSecondCard.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT, keyCode: $.ui.keyCode.LEFT, }));
+ assert.strictEqual(document.activeElement, $firstColumnFisrtCard[0], "LEFT should select the first card of the first column");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('keyboard navigation on kanban grouped rendering with empty columns', async function (assert) {
+ assert.expect(2);
+
+ var data = this.data;
+ data.partner.records[1].state = "abc";
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="bar"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['state'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ return this._super.apply(this, arguments).then(function (result) {
+ // add 2 empty columns in the middle
+ result.groups.splice(1, 0, {state_count: 0, state: 'def',
+ __domain: [["state", "=", "def"]]});
+ result.groups.splice(1, 0, {state_count: 0, state: 'def',
+ __domain: [["state", "=", "def"]]});
+
+ // add 1 empty column in the beginning and the end
+ result.groups.unshift({state_count: 0, state: 'def',
+ __domain: [["state", "=", "def"]]});
+ result.groups.push({state_count: 0, state: 'def',
+ __domain: [["state", "=", "def"]]});
+ return result;
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ /**
+ * DEF columns are empty
+ *
+ * | DEF | ABC | DEF | DEF | GHI | DEF
+ * |-----|------|-----|-----|------|-----
+ * | | yop | | | gnap |
+ * | | blip | | | blip |
+ */
+ var $yop = kanban.$('.o_kanban_record:first');
+ var $gnap = kanban.$('.o_kanban_group:eq(4) .o_kanban_record:first');
+
+ $yop.focus();
+
+ //RIGHT should select the next column that has a card
+ $yop.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT,
+ keyCode: $.ui.keyCode.RIGHT, }));
+ assert.strictEqual(document.activeElement, $gnap[0],
+ "RIGHT should select the first card of the next column that has a card");
+
+ //LEFT should go back to the first column that has a card
+ $gnap.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT,
+ keyCode: $.ui.keyCode.LEFT, }));
+ assert.strictEqual(document.activeElement, $yop[0],
+ "LEFT should select the first card of the first column that has a card");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('keyboard navigation on kanban when the focus is on a link that ' +
+ 'has an action and the kanban has no oe_kanban_global_... class', async function (assert) {
+ assert.expect(1);
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><a type="edit">Edit</a></div>' +
+ '</t></templates></kanban>',
+ });
+
+ testUtils.mock.intercept(kanban, 'switch_view', function (event) {
+ assert.deepEqual(event.data, {
+ view_type: 'form',
+ res_id: 1,
+ mode: 'edit',
+ model: 'partner',
+ }, 'When selecting focusing a card and hitting ENTER, the first link or button is clicked');
+ });
+ kanban.$('.o_kanban_record').first().focus().trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ENTER,
+ which: $.ui.keyCode.ENTER,
+ }));
+ await testUtils.nextTick();
+
+ kanban.destroy();
+ });
+
+ QUnit.test('asynchronous rendering of a field widget (ungrouped)', async function (assert) {
+ assert.expect(4);
+
+ var fooFieldProm = makeTestPromise();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldProm;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.strictEqual($('.o_kanban_record').length, 0, "kanban view is not ready yet");
+
+ fooFieldProm.resolve();
+ await nextTick();
+ assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED");
+
+ // reload with a domain
+ fooFieldProm = makeTestPromise();
+ kanbanController.reload({domain: [['id', '=', 1]]});
+ await nextTick();
+
+ assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED");
+
+ fooFieldProm.resolve();
+ await nextTick();
+ assert.strictEqual($('.o_kanban_record').text(), "LOADED");
+
+ kanbanController.destroy();
+ delete fieldRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('asynchronous rendering of a field widget (grouped)', async function (assert) {
+ assert.expect(4);
+
+ var fooFieldProm = makeTestPromise();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldProm;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ groupBy: ['foo'],
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.strictEqual($('.o_kanban_record').length, 0, "kanban view is not ready yet");
+
+ fooFieldProm.resolve();
+ await nextTick();
+ assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED");
+
+ // reload with a domain
+ fooFieldProm = makeTestPromise();
+ kanbanController.reload({domain: [['id', '=', 1]]});
+ await nextTick();
+
+ assert.strictEqual($('.o_kanban_record').text(), "LOADEDLOADEDLOADEDLOADED");
+
+ fooFieldProm.resolve();
+ await nextTick();
+ assert.strictEqual($('.o_kanban_record').text(), "LOADED");
+
+ kanbanController.destroy();
+ delete fieldRegistry.map.asyncWidget;
+ });
+ QUnit.test('asynchronous rendering of a field widget with display attr', async function (assert) {
+ assert.expect(3);
+
+ var fooFieldDef = makeTestPromise();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createAsyncView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" display="right" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.containsNone(document.body, '.o_kanban_record');
+
+ fooFieldDef.resolve();
+ await nextTick();
+ assert.strictEqual(kanbanController.$('.o_kanban_record').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+ assert.hasClass(kanbanController.$('.o_kanban_record:first .o_field_char'), 'float-right');
+
+ kanbanController.destroy();
+ delete fieldRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('asynchronous rendering of a widget', async function (assert) {
+ assert.expect(2);
+
+ var widgetDef = makeTestPromise();
+ widgetRegistry.add('asyncwidget', Widget.extend({
+ willStart: function () {
+ return widgetDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createAsyncView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><widget name="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.containsNone(document.body, '.o_kanban_record');
+
+ widgetDef.resolve();
+ await nextTick();
+ assert.strictEqual(kanbanController.$('.o_kanban_record .o_widget').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+
+ kanbanController.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('update kanban with asynchronous field widget', async function (assert) {
+ assert.expect(3);
+
+ var fooFieldDef = makeTestPromise();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanban = await testUtils.createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ domain: [['id', '=', '0']], // no record matches this domain
+ });
+
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ kanban.update({domain: []}); // this rendering will be async
+
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ fooFieldDef.resolve();
+ await nextTick();
+
+ assert.strictEqual(kanban.$('.o_kanban_record').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+
+ kanban.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('set cover image', async function (assert) {
+ assert.expect(6);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="name"/>' +
+ '<div class="o_dropdown_kanban dropdown">' +
+ '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' +
+ '<span class="fa fa-bars fa-lg"/>' +
+ '</a>' +
+ '<div class="dropdown-menu" role="menu">' +
+ '<a type="set_cover" data-field="displayed_image_id" class="dropdown-item">Set Cover Image</a>'+
+ '</div>' +
+ '</div>' +
+ '<div>'+
+ '<field name="displayed_image_id" widget="attachment_image"/>'+
+ '</div>'+
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (args.model === 'partner' && args.method === 'write') {
+ assert.step(String(args.args[0][0]));
+ return this._super(route, args);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var $firstRecord = kanban.$('.o_kanban_record:first');
+ testUtils.kanban.toggleRecordDropdown($firstRecord);
+ await testUtils.dom.click($firstRecord.find('[data-type=set_cover]'));
+ assert.containsNone($firstRecord, 'img', "Initially there is no image.");
+
+ await testUtils.dom.click($('.modal').find("img[data-id='1']"));
+ await testUtils.modal.clickButton('Select');
+ assert.containsOnce(kanban, 'img[data-src*="/web/image/1"]');
+
+ var $secondRecord = kanban.$('.o_kanban_record:nth(1)');
+ testUtils.kanban.toggleRecordDropdown($secondRecord);
+ await testUtils.dom.click($secondRecord.find('[data-type=set_cover]'));
+ $('.modal').find("img[data-id='2']").dblclick();
+ await testUtils.nextTick();
+ assert.containsOnce(kanban, 'img[data-src*="/web/image/2"]');
+ assert.verifySteps(["1", "2"], "should writes on both kanban records");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('ungrouped kanban with handle field', async function (assert) {
+ assert.expect(4);
+
+ var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="int_field" widget="handle" />' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.deepEqual(args.ids, envIDs,
+ "should write the sequence in correct order");
+ return Promise.resolve(true);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.hasClass(kanban.$('.o_kanban_view'), 'ui-sortable');
+ assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(),
+ 'yopblipgnapblip');
+
+ var $record = kanban.$('.o_kanban_view .o_kanban_record:first');
+ var $to = kanban.$('.o_kanban_view .o_kanban_record:nth-child(4)');
+ envIDs = [2, 3, 4, 1]; // first record of moved after last one
+ await testUtils.dom.dragAndDrop($record, $to, {position: "bottom"});
+
+ assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(),
+ 'blipgnapblipyop');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('ungrouped kanban without handle field', async function (assert) {
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.ok(false, "should not trigger a resequencing");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'ui-sortable');
+ assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(),
+ 'yopblipgnapblip');
+
+ var $draggedRecord = kanban.$('.o_kanban_view .o_kanban_record:first');
+ var $to = kanban.$('.o_kanban_view .o_kanban_record:nth-child(4)');
+ await testUtils.dom.dragAndDrop($draggedRecord, $to, {position: "bottom"});
+
+ assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').text(),
+ 'yopblipgnapblip');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('click on image field in kanban with oe_kanban_global_click', async function (assert) {
+ assert.expect(2);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<templates><t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<field name="image" widget="image"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ mockRPC: function (route) {
+ if (route.startsWith('data:image')) {
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ switch_view: function (event) {
+ assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), {
+ mode: 'readonly',
+ model: 'partner',
+ res_id: 1,
+ view_type: 'form',
+ }, "should trigger an event to open the clicked record in a form view");
+ },
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ await testUtils.dom.click(kanban.$('.o_field_image').first());
+
+ kanban.destroy();
+ });
+
+ QUnit.test('kanban view with boolean field', async function (assert) {
+ assert.expect(2);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="bar"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ });
+
+ assert.containsN(kanban, '.o_kanban_record:contains(True)', 3);
+ assert.containsOnce(kanban, '.o_kanban_record:contains(False)');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('kanban view with boolean widget', async function (assert) {
+ assert.expect(1);
+
+ const kanban = await testUtils.createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="bar" widget="boolean"/></div>
+ </t>
+ </templates>
+ </kanban>
+ `,
+ });
+
+ assert.containsOnce(kanban.el.querySelector('.o_kanban_record'),
+ 'div.custom-checkbox.o_field_boolean');
+ kanban.destroy();
+ });
+
+ QUnit.test('kanban view with monetary and currency fields without widget', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <field name="currency_id"/>
+ <templates><t t-name="kanban-box">
+ <div><field name="salary"/></div>
+ </t></templates>
+ </kanban>`,
+ session: {
+ currencies: _.indexBy(this.data.currency.records, 'id'),
+ },
+ });
+
+ const kanbanRecords = kanban.el.querySelectorAll('.o_kanban_record:not(.o_kanban_ghost)');
+ assert.deepEqual([...kanbanRecords].map(r => r.innerText),
+ ['$ 1750.00', '$ 1500.00', '2000.00 €', '$ 2222.00']);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("quick create: keyboard navigation to buttons", async function (assert) {
+ assert.expect(2);
+
+ const kanban = await createView({
+ arch: `
+ <kanban on_create="quick_create">
+ <field name="bar"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="display_name"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ["bar"],
+ model: "partner",
+ View: KanbanView,
+ });
+
+ // Open quick create
+ await testUtils.kanban.clickCreate(kanban);
+
+ assert.containsOnce(kanban, ".o_kanban_group:first .o_kanban_quick_create");
+
+ const $displayName = kanban.$(".o_kanban_quick_create .o_field_widget[name=display_name]");
+
+ // Fill in mandatory field
+ await testUtils.fields.editInput($displayName, "aaa");
+ // Tab -> goes to first primary button
+ await testUtils.dom.triggerEvent($displayName, "keydown", {
+ keyCode: $.ui.keyCode.TAB,
+ which: $.ui.keyCode.TAB,
+ });
+
+ assert.hasClass(document.activeElement, "btn btn-primary o_kanban_add");
+
+ kanban.destroy();
+ });
+});
+
+});
diff --git a/addons/web/static/tests/views/list_benchmarks.js b/addons/web/static/tests/views/list_benchmarks.js
new file mode 100644
index 00000000..92d70054
--- /dev/null
+++ b/addons/web/static/tests/views/list_benchmarks.js
@@ -0,0 +1,113 @@
+odoo.define('web.list_benchmarks', function (require) {
+ "use strict";
+
+ const ListView = require('web.ListView');
+ const { createView } = require('web.test_utils');
+
+ QUnit.module('List View', {
+ beforeEach: function () {
+ this.data = {
+ foo: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ bar: {string: "Bar", type: "boolean"},
+ int_field: {string: "int_field", type: "integer", sortable: true},
+ qux: {string: "my float", type: "float"},
+ },
+ records: [
+ {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4},
+ {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13},
+ ]
+ },
+ };
+ this.arch = null;
+ this.run = function (assert, cb) {
+ const data = this.data;
+ const arch = this.arch;
+ return new Promise(resolve => {
+ new Benchmark.Suite({})
+ .add('list', {
+ defer: true,
+ fn: async (deferred) => {
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data,
+ arch,
+ });
+ if (cb) {
+ cb(list);
+ }
+ list.destroy();
+ deferred.resolve();
+ },
+ })
+ .on('cycle', event => {
+ assert.ok(true, String(event.target));
+ })
+ .on('complete', resolve)
+ .run({ async: true });
+ });
+ };
+ }
+ }, function () {
+ QUnit.test('simple readonly list with 2 rows and 2 fields', function (assert) {
+ assert.expect(1);
+
+ this.arch = '<tree><field name="foo"/><field name="int_field"/></tree>';
+ return this.run(assert);
+ });
+
+ QUnit.test('simple readonly list with 200 rows and 2 fields', function (assert) {
+ assert.expect(1);
+
+ for (let i = 2; i < 200; i++) {
+ this.data.foo.records.push({
+ id: i,
+ foo: "automated data",
+ int_field: 10 * i,
+ });
+ }
+ this.arch = '<tree><field name="foo"/><field name="int_field"/></tree>';
+ return this.run(assert);
+ });
+
+ QUnit.test('simple readonly list with 200 rows and 2 fields (with widgets)', function (assert) {
+ assert.expect(1);
+
+ for (let i = 2; i < 200; i++) {
+ this.data.foo.records.push({
+ id: i,
+ foo: "automated data",
+ int_field: 10 * i,
+ });
+ }
+ this.arch = '<tree><field name="foo" widget="char"/><field name="int_field" widget="integer"/></tree>';
+ return this.run(assert);
+ });
+
+ QUnit.test('editable list with 200 rows 4 fields', function (assert) {
+ assert.expect(1);
+
+ for (let i = 2; i < 200; i++) {
+ this.data.foo.records.push({
+ id: i,
+ foo: "automated data",
+ int_field: 10 * i,
+ bar: i % 2 === 0,
+ });
+ }
+ this.arch = `
+ <tree editable="bottom">
+ <field name="foo" attrs="{'readonly': [['bar', '=', True]]}"/>
+ <field name="int_field"/>
+ <field name="bar"/>
+ <field name="qux"/>
+ </tree>`;
+ return this.run(assert, list => {
+ list.$('.o_list_button_add').click();
+ list.$('.o_list_button_discard').click();
+ });
+ });
+ });
+});
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
new file mode 100644
index 00000000..0efeea89
--- /dev/null
+++ b/addons/web/static/tests/views/list_tests.js
@@ -0,0 +1,11702 @@
+odoo.define('web.list_tests', function (require) {
+"use strict";
+
+var AbstractFieldOwl = require('web.AbstractFieldOwl');
+var AbstractStorageService = require('web.AbstractStorageService');
+var BasicModel = require('web.BasicModel');
+var core = require('web.core');
+const Domain = require('web.Domain')
+var basicFields = require('web.basic_fields');
+var fieldRegistry = require('web.field_registry');
+var fieldRegistryOwl = require('web.field_registry_owl');
+var FormView = require('web.FormView');
+var ListRenderer = require('web.ListRenderer');
+var ListView = require('web.ListView');
+var mixins = require('web.mixins');
+var NotificationService = require('web.NotificationService');
+var RamStorage = require('web.RamStorage');
+var testUtils = require('web.test_utils');
+var widgetRegistry = require('web.widget_registry');
+var Widget = require('web.Widget');
+
+
+var _t = core._t;
+const cpHelpers = testUtils.controlPanel;
+var createActionManager = testUtils.createActionManager;
+var createView = testUtils.createView;
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ foo: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ bar: {string: "Bar", type: "boolean"},
+ date: {string: "Some Date", type: "date"},
+ int_field: {string: "int_field", type: "integer", sortable: true, group_operator: "sum"},
+ text: {string: "text field", type: "text"},
+ qux: {string: "my float", type: "float"},
+ m2o: {string: "M2O field", type: "many2one", relation: "bar"},
+ o2m: {string: "O2M field", type: "one2many", relation: "bar"},
+ m2m: {string: "M2M field", type: "many2many", relation: "bar"},
+ amount: {string: "Monetary field", type: "monetary"},
+ currency_id: {string: "Currency", type: "many2one",
+ relation: "res_currency", default: 1},
+ datetime: {string: "Datetime Field", type: 'datetime'},
+ reference: {string: "Reference Field", type: 'reference', selection: [
+ ["bar", "Bar"], ["res_currency", "Currency"], ["event", "Event"]]},
+ },
+ records: [
+ {
+ id: 1,
+ bar: true,
+ foo: "yop",
+ int_field: 10,
+ qux: 0.4,
+ m2o: 1,
+ m2m: [1, 2],
+ amount: 1200,
+ currency_id: 2,
+ date: "2017-01-25",
+ datetime: "2016-12-12 10:55:05",
+ reference: 'bar,1',
+ },
+ {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13,
+ m2o: 2, m2m: [1, 2, 3], amount: 500, reference: 'res_currency,1'},
+ {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3,
+ m2o: 1, m2m: [], amount: 300, reference: 'res_currency,2'},
+ {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9,
+ m2o: 1, m2m: [1], amount: 0},
+ ]
+ },
+ bar: {
+ fields: {},
+ records: [
+ {id: 1, display_name: "Value 1"},
+ {id: 2, display_name: "Value 2"},
+ {id: 3, display_name: "Value 3"},
+ ]
+ },
+ res_currency: {
+ fields: {
+ symbol: {string: "Symbol", type: "char"},
+ position: {
+ string: "Position",
+ type: "selection",
+ selection: [['after', 'A'], ['before', 'B']],
+ },
+ },
+ records: [
+ {id: 1, display_name: "USD", symbol: '$', position: 'before'},
+ {id: 2, display_name: "EUR", symbol: '€', position: 'after'},
+ ],
+ },
+ event: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ name: {string: "name", type: "char"},
+ },
+ records: [
+ {id: "2-20170808020000", name: "virtual"},
+ ]
+ },
+ "ir.translation": {
+ fields: {
+ lang_code: {type: "char"},
+ src: {type: "char"},
+ value: {type: "char"},
+ res_id: {type: "integer"},
+ name: {type: "char"},
+ lang: {type: "char"},
+ },
+ records: [{
+ id: 99,
+ res_id: 1,
+ value: '',
+ lang_code: 'en_US',
+ lang: 'en_US',
+ name: 'foo,foo'
+ },{
+ id: 100,
+ res_id: 1,
+ value: '',
+ lang_code: 'fr_BE',
+ lang: 'fr_BE',
+ name: 'foo,foo'
+ }]
+ },
+ };
+ }
+}, function () {
+
+ QUnit.module('ListView');
+
+ QUnit.test('simple readonly list', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ assert.doesNotHaveClass(list.$el, 'o_cannot_create',
+ "should not have className 'o_cannot_create'");
+
+ // 3 th (1 for checkbox, 2 for columns)
+ assert.containsN(list, 'th', 3, "should have 3 columns");
+
+ assert.strictEqual(list.$('td:contains(gnap)').length, 1, "should contain gnap");
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ assert.containsOnce(list, 'th.o_column_sortable', "should have 1 sortable column");
+
+ assert.strictEqual(list.$('thead th:nth(2)').css('text-align'), 'right',
+ "header cells of integer fields should be right aligned");
+ assert.strictEqual(list.$('tbody tr:first td:nth(2)').css('text-align'), 'right',
+ "integer cells should be right aligned");
+
+ assert.isVisible(list.$buttons.find('.o_list_button_add'));
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'));
+ assert.isNotVisible(list.$buttons.find('.o_list_button_discard'));
+ list.destroy();
+ });
+
+ QUnit.test('on_attach_callback is properly called', async function (assert) {
+ assert.expect(3);
+
+ testUtils.mock.patch(ListRenderer, {
+ on_attach_callback() {
+ assert.step('on_attach_callback');
+ this._super(...arguments);
+ },
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="display_name"/></tree>',
+ });
+
+ assert.verifySteps(['on_attach_callback']);
+ await list.reload();
+ assert.verifySteps([]);
+
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('list with create="0"', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree create="0"><field name="foo"/></tree>',
+ });
+
+ assert.hasClass(list.$el,'o_cannot_create',
+ "should have className 'o_cannot_create'");
+ assert.containsNone(list.$buttons, '.o_list_button_add',
+ "should not have the 'Create' button");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with delete="0"', async function (assert) {
+ assert.expect(3);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree delete="0"><field name="foo"/></tree>',
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus .o_dropdown_menu');
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with edit="0"', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree editable="top" edit="0"><field name="foo"/></tree>',
+ });
+
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+
+ await testUtils.dom.click(list.$('tr td:not(.o_list_record_selector)').first());
+ assert.containsNone(list, 'tbody tr.o_selected_row', "should not have editable row");
+
+ list.destroy();
+ });
+
+ QUnit.test('export feature in list for users not in base.group_allow_export', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: { hasActionMenus: true },
+ arch: '<tree><field name="foo"/></tree>',
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_buttons .o_list_export_xlsx');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ await cpHelpers.toggleActionMenu(list);
+ assert.deepEqual(cpHelpers.getMenuItemTexts(list), ['Delete'],
+ 'action menu should not contain the Export button');
+
+ list.destroy();
+ });
+
+ QUnit.test('list with export button', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree><field name="foo"/></tree>',
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return true;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_buttons .o_list_export_xlsx');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ await cpHelpers.toggleActionMenu(list);
+ assert.deepEqual(
+ cpHelpers.getMenuItemTexts(list),
+ ['Export', 'Delete'],
+ 'action menu should have Export button'
+ );
+
+ list.destroy();
+ });
+
+ QUnit.test('export button in list view', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return true;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.isVisible(list.$('.o_list_export_xlsx'));
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+
+ assert.isNotVisible(list.$('.o_list_export_xlsx'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.isVisible(list.$('.o_list_export_xlsx'));
+
+ list.destroy();
+ });
+
+ QUnit.test('export button in empty list view', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: "foo",
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ domain: [["id", "<", 0]], // such that no record matches the domain
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return true;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.isNotVisible(list.el.querySelector('.o_list_export_xlsx'));
+
+ await list.reload({ domain: [['id', '>', 0]] });
+ assert.isVisible(list.el.querySelector('.o_list_export_xlsx'));
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 4,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and invisible field', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo" invisible="1"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and invisible field (modifier)', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo" attrs="{'invisible': [['foo', '=', 'blip']]}"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 4,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and optional field', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo" optional="hide"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons with invisible modifier', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button name="x" type="object" icon="fa-star" attrs="{'invisible': [['foo', '=', 'blip']]}"/>
+ <button name="y" type="object" icon="fa-refresh" attrs="{'invisible': [['foo', '=', 'yop']]}"/>
+ <button name="z" type="object" icon="fa-exclamation" attrs="{'invisible': [['foo', '=', 'gnap']]}"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsOnce(list.$('.o_data_row:first'), 'td.o_list_button');
+ assert.strictEqual(list.$('.o_field_cell').text(), 'yopblipgnapblip');
+ assert.containsN(list, 'td button i.fa-star:visible', 2);
+ assert.containsN(list, 'td button i.fa-refresh:visible', 3);
+ assert.containsN(list, 'td button i.fa-exclamation:visible', 3);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with icon buttons', async function (assert) {
+ assert.expect(5);
+
+ this.data.foo.records.splice(1);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="x" type="object" icon="fa-asterisk"/>
+ <button name="x" type="object" icon="fa-star" class="o_yeah"/>
+ <button name="x" type="object" icon="fa-refresh" string="Refresh" class="o_yeah"/>
+ <button name="x" type="object" icon="fa-exclamation" string="Danger" class="o_yeah btn-danger"/>
+ </tree>`,
+ });
+
+ assert.containsOnce(list, 'button.btn.btn-link i.fa.fa-asterisk');
+ assert.containsOnce(list, 'button.btn.btn-link.o_yeah i.fa.fa-star');
+ assert.containsOnce(list, 'button.btn.btn-link.o_yeah:contains("Refresh") i.fa.fa-refresh');
+ assert.containsOnce(list, 'button.btn.btn-danger.o_yeah:contains("Danger") i.fa.fa-exclamation');
+ assert.containsNone(list, 'button.btn.btn-link.btn-danger');
+
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button in controlPanel basic rendering', async function (assert) {
+ assert.expect(11);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf"/>
+ <button name="y" type="object" class="plouf" string="plouf" invisible="not context.get('bim')"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ });
+ let cpButtons = cpHelpers.getButtons(list);
+ assert.containsNone(cpButtons[0], 'button[name="x"]');
+ assert.containsNone(cpButtons[0], '.o_list_selection_box');
+ assert.containsNone(cpButtons[0], 'button[name="y"]');
+
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ cpButtons = cpHelpers.getButtons(list);
+ assert.containsOnce(cpButtons[0], 'button[name="x"]');
+ assert.hasClass(cpButtons[0].querySelector('button[name="x"]'), 'btn btn-secondary');
+ assert.containsOnce(cpButtons[0], '.o_list_selection_box');
+ assert.strictEqual(
+ cpButtons[0].querySelector('button[name="x"]').previousElementSibling,
+ cpButtons[0].querySelector('.o_list_selection_box')
+ );
+ assert.containsNone(cpButtons[0], 'button[name="y"]');
+
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ cpButtons = cpHelpers.getButtons(list);
+ assert.containsNone(cpButtons[0], 'button[name="x"]');
+ assert.containsNone(cpButtons[0], '.o_list_selection_box');
+ assert.containsNone(cpButtons[0], 'button[name="y"]');
+
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button executes action on click: buttons are disabled and re-enabled', async function (assert) {
+ assert.expect(3);
+
+ const executeActionDef = testUtils.makeTestPromise();
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ intercepts: {
+ async execute_action(ev) {
+ const { on_success } = ev.data;
+ await executeActionDef;
+ on_success();
+ }
+ }
+ });
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ const cpButtons = cpHelpers.getButtons(list);
+ assert.ok(
+ Array.from(cpButtons[0].querySelectorAll('button')).every(btn => !btn.disabled)
+ );
+
+ await testUtils.dom.click(cpButtons[0].querySelector('button[name="x"]'));
+ assert.ok(
+ Array.from(cpButtons[0].querySelectorAll('button')).every(btn => btn.disabled)
+ );
+
+ executeActionDef.resolve();
+ await testUtils.nextTick();
+ assert.ok(
+ Array.from(cpButtons[0].querySelectorAll('button')).every(btn => !btn.disabled)
+ );
+
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button executes action on click: correct parameters', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf" context="{'plouf': 'plif'}"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ intercepts: {
+ async execute_action(ev) {
+ const {
+ action_data: {
+ context, name, type
+ },
+ env,
+ } = ev.data;
+ // Action's own properties
+ assert.strictEqual(name, "x");
+ assert.strictEqual(type, "object");
+
+ // The action's execution context
+ assert.deepEqual(context, {
+ active_domain: [],
+ active_id: 1,
+ active_ids: [1],
+ active_model: 'foo',
+ plouf: 'plif',
+ });
+ // The current environment (not owl's, but the current action's)
+ assert.deepEqual(env, {
+ context: {},
+ model: 'foo',
+ resIDs: [1],
+ });
+ }
+ }
+ });
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ const cpButtons = cpHelpers.getButtons(list);
+ await testUtils.dom.click(cpButtons[0].querySelector('button[name="x"]'));
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button executes action on click with domain selected: correct parameters', async function (assert) {
+ assert.expect(10);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree limit="1">
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ intercepts: {
+ async execute_action(ev) {
+ assert.step('execute_action');
+ const {
+ action_data: {
+ context, name, type
+ },
+ env,
+ } = ev.data;
+ // Action's own properties
+ assert.strictEqual(name, "x");
+ assert.strictEqual(type, "object");
+
+ // The action's execution context
+ assert.deepEqual(context, {
+ active_domain: [],
+ active_id: 1,
+ active_ids: [1, 2, 3, 4],
+ active_model: 'foo',
+ });
+ // The current environment (not owl's, but the current action's)
+ assert.deepEqual(env, {
+ context: {},
+ model: 'foo',
+ resIDs: [1, 2, 3, 4],
+ });
+ }
+ },
+ mockRPC(route, args) {
+ if (args.method === 'search') {
+ assert.step('search');
+ assert.strictEqual(args.model, 'foo');
+ assert.deepEqual(args.args, [[]]); // empty domain since no domain in searchView
+ }
+ return this._super.call(this, ...arguments);
+ }
+ });
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ const cpButtons = cpHelpers.getButtons(list);
+
+ await testUtils.dom.click(cpButtons[0].querySelector('.o_list_select_domain'));
+ assert.verifySteps([]);
+
+ await testUtils.dom.click(cpButtons[0].querySelector('button[name="x"]'));
+ assert.verifySteps([
+ 'search',
+ 'execute_action',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('column names (noLabel, label, string and default)', async function (assert) {
+ assert.expect(4);
+
+ const FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('nolabel_char', FieldChar.extend({
+ noLabel: true,
+ }));
+ fieldRegistry.add('label_char', FieldChar.extend({
+ label: "Some static label",
+ }));
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="display_name" widget="nolabel_char"/>
+ <field name="foo" widget="label_char"/>
+ <field name="int_field" string="My custom label"/>
+ <field name="text"/>
+ </tree>`,
+ });
+
+ assert.strictEqual(list.$('thead th[data-name=display_name]').text(), '');
+ assert.strictEqual(list.$('thead th[data-name=foo]').text(), 'Some static label');
+ assert.strictEqual(list.$('thead th[data-name=int_field]').text(), 'My custom label');
+ assert.strictEqual(list.$('thead th[data-name=text]').text(), 'text field');
+
+ list.destroy();
+ });
+
+ QUnit.test('simple editable rendering', async function (assert) {
+ assert.expect(15);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, 'th', 3, "should have 2 th");
+ assert.containsN(list, 'th', 3, "should have 3 th");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+ assert.containsOnce(list, 'td:contains(yop)', "should contain yop");
+
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'),
+ "should not have a visible save button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_discard'),
+ "should not have a visible discard button");
+
+ await testUtils.dom.click(list.$('td:not(.o_list_record_selector)').first());
+
+ assert.isNotVisible(list.$buttons.find('.o_list_button_add'),
+ "should not have a visible Create button");
+ assert.isVisible(list.$buttons.find('.o_list_button_save'),
+ "should have a visible save button");
+ assert.isVisible(list.$buttons.find('.o_list_button_discard'),
+ "should have a visible discard button");
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'),
+ "should not have a visible save button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_discard'),
+ "should not have a visible discard button");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable rendering with handle and no data', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.records = [];
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="currency_id"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ });
+ assert.containsN(list, 'thead th', 4, "there should be 4 th");
+ assert.hasClass(list.$('thead th:eq(0)'), 'o_list_record_selector');
+ assert.hasClass(list.$('thead th:eq(1)'), 'o_handle_cell');
+ assert.strictEqual(list.$('thead th:eq(1)').text(), '',
+ "the handle field shouldn't have a header description");
+ assert.strictEqual(list.$('thead th:eq(2)').attr('style'), "width: 50%;");
+ assert.strictEqual(list.$('thead th:eq(3)').attr('style'), "width: 50%;");
+ list.destroy();
+ });
+
+ QUnit.test('invisible columns are not displayed', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" invisible="1"/>' +
+ '</tree>',
+ });
+
+ // 1 th for checkbox, 1 for 1 visible column
+ assert.containsN(list, 'th', 2, "should have 2 th");
+ list.destroy();
+ });
+
+ QUnit.test('boolean field has no title', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="bar"/></tree>',
+ });
+ assert.equal(list.$('tbody tr:first td:eq(1)').attr('title'), "");
+ list.destroy();
+ });
+
+ QUnit.test('field with nolabel has no title', async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" nolabel="1"/></tree>',
+ });
+ assert.strictEqual(list.$('thead tr:first th:eq(1)').text(), "");
+ list.destroy();
+ });
+
+ QUnit.test('field titles are not escaped', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records[0].foo = '<div>Hello</div>';
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+ assert.strictEqual(list.$('tbody tr:first .o_data_cell').text(), "<div>Hello</div>");
+ assert.strictEqual(list.$('tbody tr:first .o_data_cell').attr('title'), "<div>Hello</div>");
+
+ list.destroy();
+ });
+
+ QUnit.test('record-depending invisible lines are correctly aligned', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" attrs="{\'invisible\': [(\'id\',\'=\', 1)]}"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody tr:first td', 4,
+ "there should be 4 cells in the first row");
+ assert.containsOnce(list, 'tbody td.o_invisible_modifier',
+ "there should be 1 invisible bar cell");
+ assert.hasClass(list.$('tbody tr:first td:eq(2)'),'o_invisible_modifier',
+ "the 3rd cell should be invisible");
+ assert.containsN(list, 'tbody tr:eq(0) td:visible', list.$('tbody tr:eq(1) td:visible').length,
+ "there should be the same number of visible cells in different rows");
+ list.destroy();
+ });
+
+ QUnit.test('do not perform extra RPC to read invisible many2one fields', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.m2o.default = 2;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="m2o" invisible="1"/>' +
+ '</tree>',
+ mockRPC: function (route) {
+ assert.step(_.last(route.split('/')));
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.verifySteps(['search_read', 'onchange'], "no nameget should be done");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list datetimepicker destroy widget (edition)', async function (assert) {
+ assert.expect(7);
+ var eventPromise = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+ list.$el.on({
+ 'show.datetimepicker': async function () {
+ assert.containsOnce(list, '.o_selected_row');
+ assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget');
+
+ await testUtils.fields.triggerKeydown(list.$('.o_datepicker_input'), 'escape');
+
+ assert.containsOnce(list, '.o_selected_row');
+ assert.containsNone($('body'), '.bootstrap-datetimepicker-widget');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+
+ assert.containsNone(list, '.o_selected_row');
+
+ eventPromise.resolve();
+ }
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.dom.openDatepicker(list.$('.o_datepicker'));
+
+ await eventPromise;
+ list.destroy();
+ });
+
+ QUnit.test('editable list datetimepicker destroy widget (new line)', async function (assert) {
+ assert.expect(10);
+ var eventPromise = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+ list.$el.on({
+ 'show.datetimepicker': async function () {
+ assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.containsOnce(list, '.o_selected_row');
+
+ await testUtils.fields.triggerKeydown(list.$('.o_datepicker_input'), 'escape');
+
+ assert.containsNone($('body'), '.bootstrap-datetimepicker-widget');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.containsOnce(list, '.o_selected_row');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_selected_row');
+
+ eventPromise.resolve();
+ }
+ });
+ assert.equal(list.$('.o_data_row').length, 4,
+ 'There should be 4 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 0,
+ 'No row should be in edit mode');
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.openDatepicker(list.$('.o_datepicker'));
+
+ await eventPromise;
+ list.destroy();
+ });
+
+ QUnit.test('at least 4 rows are rendered, even if less data', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="bar"/></tree>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ list.destroy();
+ });
+
+ QUnit.test('discard a new record in editable="top" list with less than 4 records', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="bar"/></tree>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsN(list, 'tbody tr', 4);
+
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.hasClass(list.$('tbody tr:first'), 'o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_list_button_discard'));
+
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsN(list, 'tbody tr', 4);
+ assert.hasClass(list.$('tbody tr:first'), 'o_data_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(list.$('th:contains(Foo)').length, 1, "should contain Foo");
+ assert.strictEqual(list.$('th:contains(Bar)').length, 1, "should contain Bar");
+ assert.containsN(list, 'tr.o_group_header', 2, "should have 2 .o_group_header");
+ assert.containsN(list, 'th.o_group_name', 2, "should have 2 .o_group_name");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering with widget="handle" col', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(list.$('th:contains(Foo)').length, 1, "should contain Foo");
+ assert.strictEqual(list.$('th:contains(Bar)').length, 1, "should contain Bar");
+ assert.containsN(list, 'tr.o_group_header', 2, "should have 2 .o_group_header");
+ assert.containsN(list, 'th.o_group_name', 2, "should have 2 .o_group_name");
+ assert.containsNone(list, 'th:contains(int_field)', "Should not have int_field in grouped list");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 1 col without selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: false,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 1,
+ "group header should have exactly 1 column");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "1",
+ "the header should span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 1 col with selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: true,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 1,
+ "group header should have exactly 1 column");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2",
+ "the header should span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 2 cols without selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: false,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 column");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "1",
+ "the header should not span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 3 cols without selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/><field name="text"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: false,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 columns");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2",
+ "the first header should span two columns");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 2 col with selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: true,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 columns");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2",
+ "the header should not span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 3 cols with selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/><field name="text"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: true,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 columns");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "3",
+ "the header should not span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 7 cols with aggregates and selector', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="datetime"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" sum="Sum1"/>' +
+ '<field name="bar"/>' +
+ '<field name="qux" sum="Sum2"/>' +
+ '<field name="date"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 5,
+ "group header should have exactly 5 columns (one before first aggregate, one after last aggregate, and all in between");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "3",
+ "header name should span on the two first fields + selector (colspan 3)");
+ assert.containsN(list, '.o_group_header:first td', 3,
+ "there should be 3 tds (aggregates + fields in between)");
+ assert.strictEqual(list.$('.o_group_header:first th:last').attr('colspan'), "2",
+ "header last cell should span on the two last fields (to give space for the pager) (colspan 2)");
+ list.destroy();
+ });
+
+ QUnit.test('ordered list, sort attribute in context', async function (assert) {
+ assert.expect(1);
+ // Equivalent to saving a custom filter
+
+ this.data.foo.fields.foo.sortable = true;
+ this.data.foo.fields.date.sortable = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+
+ // Descending order on Foo
+ await testUtils.dom.click(list.$('th.o_column_sortable:contains("Foo")'));
+ await testUtils.dom.click(list.$('th.o_column_sortable:contains("Foo")'));
+
+ // Ascending order on Date
+ await testUtils.dom.click(list.$('th.o_column_sortable:contains("Date")'));
+
+ var listContext = list.getOwnedQueryParams();
+ assert.deepEqual(listContext,
+ {
+ orderedBy: [{
+ name: 'date',
+ asc: true,
+ }, {
+ name: 'foo',
+ asc: false,
+ }]
+ }, 'the list should have the right orderedBy in context');
+ list.destroy();
+ });
+
+ QUnit.test('Loading a filter with a sort attribute', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.fields.foo.sortable = true;
+ this.data.foo.fields.date.sortable = true;
+
+ var searchReads = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="date"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ if (searchReads === 0) {
+ assert.strictEqual(args.sort, 'date ASC, foo DESC',
+ 'The sort attribute of the filter should be used by the initial search_read');
+ } else if (searchReads === 1) {
+ assert.strictEqual(args.sort, 'date DESC, foo ASC',
+ 'The sort attribute of the filter should be used by the next search_read');
+ }
+ searchReads += 1;
+ }
+ return this._super.apply(this,arguments);
+ },
+ favoriteFilters : [
+ {
+ context: "{}",
+ domain: "[]",
+ id: 7,
+ is_default: true,
+ name: "My favorite",
+ sort: "[\"date asc\", \"foo desc\"]",
+ user_id: [2, "Mitchell Admin"],
+ }, {
+ context: "{}",
+ domain: "[]",
+ id: 8,
+ is_default: false,
+ name: "My second favorite",
+ sort: "[\"date desc\", \"foo asc\"]",
+ user_id: [2, "Mitchell Admin"],
+ }
+ ]
+ });
+
+
+ await cpHelpers.toggleFavoriteMenu(list);
+ await cpHelpers.toggleMenuItem(list, "My second favorite");
+
+ list.destroy();
+ });
+
+ QUnit.test('many2one field rendering', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="m2o"/></tree>',
+ });
+
+ assert.ok(list.$('td:contains(Value 1)').length,
+ "should have the display_name of the many2one");
+ list.destroy();
+ });
+
+ QUnit.test('grouped list view, with 1 open group', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="int_field"/></tree>',
+ groupBy: ['foo'],
+ });
+
+ await testUtils.dom.click(list.$('th.o_group_name:nth(1)'));
+ await testUtils.nextTick();
+ assert.containsN(list, 'tbody:eq(1) tr', 2, "open group should contain 2 records");
+ assert.containsN(list, 'tbody', 3, "should contain 3 tbody");
+ assert.containsOnce(list, 'td:contains(9)', "should contain 9");
+ assert.containsOnce(list, 'td:contains(-4)', "should contain -4");
+ assert.containsOnce(list, 'td:contains(10)', "should contain 10");
+ assert.containsOnce(list, 'tr.o_group_header td:contains(10)', "but 10 should be in a header");
+ list.destroy();
+ });
+
+ QUnit.test('opening records when clicking on record', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+ testUtils.mock.intercept(list, "open_record", function () {
+ assert.ok("list view should trigger 'open_record' event");
+ });
+
+ await testUtils.dom.click(list.$('tr td:not(.o_list_record_selector)').first());
+ list.update({groupBy: ['foo']});
+ await testUtils.nextTick();
+
+ assert.containsN(list, 'tr.o_group_header', 3, "list should be grouped");
+ await testUtils.dom.click(list.$('th.o_group_name').first());
+
+ testUtils.dom.click(list.$('tr:not(.o_group_header) td:not(.o_list_record_selector)').first());
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: readonly fields cannot be edited', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.fields.foo.readonly = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '</tree>',
+ });
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+ var $second_td = list.$('td:not(.o_list_record_selector)').eq(1);
+ var $third_td = list.$('td:not(.o_list_record_selector)').eq(2);
+ await testUtils.dom.click($td);
+ assert.hasClass($td.parent(),'o_selected_row',
+ "row should be in edit mode");
+ assert.hasClass($td,'o_readonly_modifier',
+ "foo cell should be readonly in edit mode");
+ assert.doesNotHaveClass($second_td, 'o_readonly_modifier',
+ "bar cell should be editable");
+ assert.hasClass($third_td,'o_readonly_modifier',
+ "int_field cell should be readonly in edit mode");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: line with no active element', async function (assert) {
+ assert.expect(3);
+
+ this.data.bar = {
+ fields: {
+ titi: {string: "Char", type: "char"},
+ grosminet: {string: "Bool", type: "boolean"},
+ },
+ records: [
+ {id: 1, titi: 'cui', grosminet: true},
+ {id: 2, titi: 'cuicui', grosminet: false},
+ ],
+ };
+ this.data.foo.records[0].o2m = [1, 2];
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>'+
+ '<field name="o2m">'+
+ '<tree editable="top">'+
+ '<field name="titi" readonly="1"/>'+
+ '<field name="grosminet" widget="boolean_toggle"/>'+
+ '</tree>'+
+ '</field>'+
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {
+ o2m: [[1, 1, {grosminet: false}], [4, 2, false]],
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var $td = form.$('.o_data_cell').first();
+ var $td2 = form.$('.o_data_cell').eq(1);
+ assert.hasClass($td, 'o_readonly_modifier');
+ assert.hasClass($td2, 'o_boolean_toggle_cell');
+ await testUtils.dom.click($td);
+ await testUtils.dom.click($td2.find('.o_boolean_toggle input'));
+ await testUtils.nextTick();
+
+ await testUtils.form.clickSave(form);
+ await testUtils.nextTick();
+ form.destroy();
+ });
+
+ QUnit.test('editable list view: click on last element after creation empty new line', async function (assert) {
+ assert.expect(1);
+
+ this.data.bar = {
+ fields: {
+ titi: {string: "Char", type: "char", required: true},
+ int_field: {string: "int_field", type: "integer", sortable: true, required: true}
+ },
+ records: [
+ {id: 1, titi: 'cui', int_field: 2},
+ {id: 2, titi: 'cuicui', int_field: 4},
+ ],
+ };
+ this.data.foo.records[0].o2m = [1, 2];
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>'+
+ '<field name="o2m">'+
+ '<tree editable="top">'+
+ '<field name="int_field" widget="handle"/>'+
+ '<field name="titi"/>'+
+ '</tree>'+
+ '</field>'+
+ '</form>',
+ });
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add > a'));
+ await testUtils.dom.click(form.$('.o_data_row:last() > td.o_list_char'));
+ // This test ensure that they aren't traceback when clicking on the last row.
+ assert.containsN(form, '.o_data_row', 2, "list should have exactly 2 rows");
+ form.destroy();
+ });
+
+ QUnit.test('edit field in editable field without editing the row', async function (assert) {
+ // some widgets are editable in readonly (e.g. priority, boolean_toggle...) and they
+ // thus don't require the row to be switched in edition to be edited
+ assert.expect(13);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="top">
+ <field name="foo"/>
+ <field name="bar" widget="boolean_toggle"/>
+ </tree>`,
+ mockRPC(route, args) {
+ if (args.method === 'write') {
+ assert.step('write: ' + args.args[1].bar);
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // toggle the boolean value of the first row without editing the row
+ assert.ok(list.$('.o_data_row:first .o_boolean_toggle input')[0].checked);
+ assert.containsNone(list, '.o_selected_row');
+ await testUtils.dom.click(list.$('.o_data_row:first .o_boolean_toggle'));
+ assert.notOk(list.$('.o_data_row:first .o_boolean_toggle input')[0].checked);
+ assert.containsNone(list, '.o_selected_row');
+ assert.verifySteps(['write: false']);
+
+ // toggle the boolean value after switching the row in edition
+ assert.containsNone(list, '.o_selected_row');
+ await testUtils.dom.click(list.$('.o_data_row .o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row');
+ await testUtils.dom.click(list.$('.o_selected_row .o_boolean_toggle'));
+ assert.containsOnce(list, '.o_selected_row');
+ assert.verifySteps([]);
+
+ // save
+ await testUtils.dom.click(list.$('.o_list_button_save'));
+ assert.containsNone(list, '.o_selected_row');
+ assert.verifySteps(['write: true']);
+
+ list.destroy();
+ });
+
+ QUnit.test('basic operations for editable list renderer', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+ assert.doesNotHaveClass($td.parent(), 'o_selected_row', "td should not be in edit mode");
+ await testUtils.dom.click($td);
+ assert.hasClass($td.parent(),'o_selected_row', "td should be in edit mode");
+ list.destroy();
+ });
+
+ QUnit.test('editable list: add a line and discard', async function (assert) {
+ assert.expect(11);
+
+ testUtils.mock.patch(basicFields.FieldChar, {
+ destroy: function () {
+ assert.step('destroy');
+ this._super.apply(this, arguments);
+ },
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ domain: [['foo', '=', 'yop']],
+ });
+
+
+ assert.containsN(list, 'tbody tr', 4,
+ "list should contain 4 rows");
+ assert.containsOnce(list, '.o_data_row',
+ "list should contain one record (and thus 3 empty rows)");
+
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-1',
+ "pager should be correct");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsN(list, 'tbody tr', 4,
+ "list should still contain 4 rows");
+ assert.containsN(list, '.o_data_row', 2,
+ "list should contain two record (and thus 2 empty rows)");
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-2',
+ "pager should be correct");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+
+ assert.containsN(list, 'tbody tr', 4,
+ "list should still contain 4 rows");
+ assert.containsOnce(list, '.o_data_row',
+ "list should contain one record (and thus 3 empty rows)");
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-1',
+ "pager should be correct");
+ assert.verifySteps(['destroy'],
+ "should have destroyed the widget of the removed line");
+
+ testUtils.mock.unpatch(basicFields.FieldChar);
+ list.destroy();
+ });
+
+ QUnit.test('field changes are triggered correctly', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+
+ var n = 0;
+ testUtils.mock.intercept(list, "field_changed", function () {
+ n += 1;
+ });
+ await testUtils.dom.click($td);
+ await testUtils.fields.editInput($td.find('input'), 'abc');
+ assert.strictEqual(n, 1, "field_changed should have been triggered");
+ await testUtils.dom.click(list.$('td:not(.o_list_record_selector)').eq(2));
+ assert.strictEqual(n, 1, "field_changed should not have been triggered");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: basic char field edition', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+ await testUtils.dom.click($td);
+ await testUtils.fields.editInput($td.find('input'), 'abc');
+ assert.strictEqual($td.find('input').val(), 'abc', "char field has been edited correctly");
+
+ var $next_row_td = list.$('tbody tr:eq(1) td:not(.o_list_record_selector)').first();
+ await testUtils.dom.click($next_row_td);
+ assert.strictEqual(list.$('td:not(.o_list_record_selector)').first().text(), 'abc',
+ 'changes should be saved correctly');
+ assert.doesNotHaveClass(list.$('tbody tr').first(), 'o_selected_row',
+ 'saved row should be in readonly mode');
+ assert.strictEqual(this.data.foo.records[0].foo, 'abc',
+ "the edition should have been properly saved");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: save data when list sorting in edit mode', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.foo.sortable = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1], {foo: 'xyz'}],
+ "should correctly save the edited record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('input[name="foo"]'), 'xyz');
+ await testUtils.dom.click(list.$('.o_column_sortable'));
+
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should still be in edition");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ assert.doesNotHaveClass(list.$buttons, 'o-editing',
+ "list buttons should be back to their readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: check that controlpanel buttons are updating when groupby applied', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.fields.foo = {string: "Foo", type: "char", required:true};
+
+ var actionManager = await createActionManager({
+ actions: [{
+ id: 11,
+ name: 'Partners Action 11',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[3, 'list']],
+ search_view_id: [9, 'search'],
+ }],
+ archs: {
+ 'foo,3,list': '<tree editable="top"><field name="display_name"/><field name="foo"/></tree>',
+
+ 'foo,9,search': '<search>'+
+ '<filter string="candle" name="itsName" context="{\'group_by\': \'foo\'}"/>' +
+ '</search>',
+ },
+ data: this.data,
+ });
+
+ await actionManager.doAction(11);
+ await testUtils.dom.click(actionManager.$('.o_list_button_add'));
+
+ assert.isNotVisible(actionManager.$('.o_list_button_add'),
+ "create button should be invisible");
+ assert.isVisible(actionManager.$('.o_list_button_save'), "save button should be visible");
+
+ await testUtils.dom.click(actionManager.$('.o_dropdown_toggler_btn:contains("Group By")'));
+ await testUtils.dom.click(actionManager.$('.o_group_by_menu .o_menu_item a:contains("candle")'));
+
+ assert.isNotVisible(actionManager.$('.o_list_button_add'), "create button should be invisible");
+ assert.isNotVisible(actionManager.$('.o_list_button_save'),
+ "save button should be invisible after applying groupby");
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('list view not groupable', async function (assert) {
+ assert.expect(2);
+
+ const searchMenuTypesOriginal = ListView.prototype.searchMenuTypes;
+ ListView.prototype.searchMenuTypes = ['filter', 'favorite'];
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="top">
+ <field name="display_name"/>
+ <field name="foo"/>
+ </tree>
+ `,
+ archs: {
+ 'foo,false,search': `
+ <search>
+ <filter context="{'group_by': 'foo'}" name="foo"/>
+ </search>
+ `,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ throw new Error("Should not do a read_group RPC");
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: { search_default_foo: 1, },
+ });
+
+ assert.containsNone(list, '.o_control_panel div.o_search_options div.o_group_by_menu',
+ "there should not be groupby menu");
+ assert.deepEqual(cpHelpers.getFacetTexts(list), []);
+
+ list.destroy();
+ ListView.prototype.searchMenuTypes = searchMenuTypesOriginal;
+ });
+
+ QUnit.test('selection changes are triggered correctly', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ });
+ var $tbody_selector = list.$('tbody .o_list_record_selector input').first();
+ var $thead_selector = list.$('thead .o_list_record_selector input');
+
+ var n = 0;
+ testUtils.mock.intercept(list, "selection_changed", function () {
+ n += 1;
+ });
+
+ // tbody checkbox click
+ testUtils.dom.click($tbody_selector);
+ assert.strictEqual(n, 1, "selection_changed should have been triggered");
+ assert.ok($tbody_selector.is(':checked'), "selection checkbox should be checked");
+ testUtils.dom.click($tbody_selector);
+ assert.strictEqual(n, 2, "selection_changed should have been triggered");
+ assert.ok(!$tbody_selector.is(':checked'), "selection checkbox shouldn't be checked");
+
+ // head checkbox click
+ testUtils.dom.click($thead_selector);
+ assert.strictEqual(n, 3, "selection_changed should have been triggered");
+ assert.containsN(list, 'tbody .o_list_record_selector input:checked',
+ list.$('tbody tr').length, "all selection checkboxes should be checked");
+
+ testUtils.dom.click($thead_selector);
+ assert.strictEqual(n, 4, "selection_changed should have been triggered");
+
+ assert.containsNone(list, 'tbody .o_list_record_selector input:checked',
+ "no selection checkbox should be checked");
+ list.destroy();
+ });
+
+ QUnit.test('Row selection checkbox can be toggled by clicking on the cell', async function (assert) {
+ assert.expect(9);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ testUtils.mock.intercept(list, "selection_changed", function (ev) {
+ assert.step(ev.data.selection.length.toString());
+ });
+
+ testUtils.dom.click(list.$('tbody .o_list_record_selector:first'));
+ assert.containsOnce(list, 'tbody .o_list_record_selector input:checked');
+ testUtils.dom.click(list.$('tbody .o_list_record_selector:first'));
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+
+ testUtils.dom.click(list.$('thead .o_list_record_selector'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+ testUtils.dom.click(list.$('thead .o_list_record_selector'));
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+
+ assert.verifySteps(['1', '0', '4', '0']);
+
+ list.destroy();
+ });
+
+ QUnit.test('head selector is toggled by the other selectors', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ data: this.data,
+ groupBy: ['bar'],
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.ok(!list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be unchecked");
+
+ await testUtils.dom.click(list.$('.o_group_header:first()'));
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsN(list, 'tbody .o_list_record_selector input:checked',
+ 3, "All visible checkboxes should be checked");
+
+ await testUtils.dom.click(list.$('.o_group_header:last()'));
+
+ assert.ok(!list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be unchecked");
+
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector input:last()'));
+
+ assert.ok(list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be checked");
+
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:first() input'));
+
+ assert.ok(!list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be unchecked");
+
+ await testUtils.dom.click(list.$('.o_group_header:first()'));
+
+ assert.ok(list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be checked");
+
+ list.destroy();
+ });
+
+ QUnit.test('selection box is properly displayed (single page)', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '1 selected');
+
+ // select all records of first page
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '4 selected');
+
+ // unselect a record
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '3 selected');
+
+ list.destroy();
+ });
+
+ QUnit.test('selection box is properly displayed (multi pages)', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '1 selected');
+
+ // select all records of first page
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsOnce(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' ').trim(),
+ '3 selected Select all 4');
+
+ // select all domain
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), 'All 4 selected');
+
+ list.destroy();
+ });
+
+ QUnit.test('selection box is removed after multi record edition', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4,
+ "there should be 4 records");
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box',
+ "list selection box should not be displayed");
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box',
+ "list selection box should be displayed");
+ assert.containsN(list, '.o_data_row .o_list_record_selector input:checked', 4,
+ "all 4 records should be selected");
+
+ // edit selected records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'legion');
+ await testUtils.dom.click($('.modal-dialog button.btn-primary'));
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box',
+ "list selection box should not be displayed");
+ assert.containsNone(list, '.o_data_row .o_list_record_selector input:checked',
+ "no records should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('selection is reset on reload', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" sum="Sum"/>' +
+ '</tree>',
+ });
+
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '32',
+ "total should be 32 (no record selected)");
+
+ // select first record
+ var $firstRowSelector = list.$('tbody .o_list_record_selector input').first();
+ testUtils.dom.click($firstRowSelector);
+ assert.ok($firstRowSelector.is(':checked'), "first row should be selected");
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '10',
+ "total should be 10 (first record selected)");
+
+ // reload
+ await list.reload();
+ $firstRowSelector = list.$('tbody .o_list_record_selector input').first();
+ assert.notOk($firstRowSelector.is(':checked'),
+ "first row should no longer be selected");
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '32',
+ "total should be 32 (no more record selected)");
+
+ list.destroy();
+ });
+
+ QUnit.test('selection is kept on render without reload', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" sum="Sum"/>' +
+ '</tree>',
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // open blip grouping and check all lines
+ await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")'));
+ await testUtils.dom.click(list.$('.o_data_row:first input'));
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // open yop grouping and verify blip are still checked
+ await testUtils.dom.click(list.$('.o_group_header:contains("yop (1)")'));
+ assert.containsOnce(list, '.o_data_row input:checked',
+ "opening a grouping does not uncheck others");
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // close and open blip grouping and verify blip are unchecked
+ await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")'));
+ await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")'));
+ assert.containsNone(list, '.o_data_row input:checked',
+ "opening and closing a grouping uncheck its elements");
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are computed correctly', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" sum="Sum"/></tree>',
+ });
+ var $tbody_selectors = list.$('tbody .o_list_record_selector input');
+ var $thead_selector = list.$('thead .o_list_record_selector input');
+
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "32", "total should be 32");
+
+ testUtils.dom.click($tbody_selectors.first());
+ testUtils.dom.click($tbody_selectors.last());
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "6",
+ "total should be 6 as first and last records are selected");
+
+ testUtils.dom.click($thead_selector);
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "32",
+ "total should be 32 as all records are selected");
+
+ // Let's update the view to dislay NO records
+ await list.update({domain: ['&', ['bar', '=', false], ['int_field', '>', 0]]});
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "0", "total should have been recomputed to 0");
+
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are computed correctly in grouped lists', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['m2o'],
+ arch: '<tree editable="bottom"><field name="foo" /><field name="int_field" sum="Sum"/></tree>',
+ });
+
+ var $groupHeader1 = list.$('.o_group_header').filter(function (index, el) {
+ return $(el).data('group').res_id === 1;
+ });
+ var $groupHeader2 = list.$('.o_group_header').filter(function (index, el) {
+ return $(el).data('group').res_id === 2;
+ });
+ assert.strictEqual($groupHeader1.find('td:last()').text(), "23", "first group total should be 23");
+ assert.strictEqual($groupHeader2.find('td:last()').text(), "9", "second group total should be 9");
+ assert.strictEqual(list.$('tfoot td:last()').text(), "32", "total should be 32");
+
+ await testUtils.dom.click($groupHeader1);
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector input').first());
+ assert.strictEqual(list.$('tfoot td:last()').text(), "10",
+ "total should be 10 as first record of first group is selected");
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are updated when a line is edited', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="int_field" sum="Sum"/></tree>',
+ });
+
+ assert.strictEqual(list.$('td[title="Sum"]').text(), "32", "current total should be 32");
+
+ await testUtils.dom.click(list.$('tr.o_data_row td.o_data_cell').first());
+ await testUtils.fields.editInput(list.$('td.o_data_cell input'), "15");
+
+ assert.strictEqual(list.$('td[title="Sum"]').text(), "37",
+ "current total should now be 37");
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are formatted according to field widget', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="qux" widget="float_time" sum="Sum"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '19:24',
+ "total should be formatted as a float_time");
+
+ list.destroy();
+ });
+
+ QUnit.test('aggregates digits can be set with digits field attribute', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="amount" widget="monetary" sum="Sum" digits="[69,3]"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('.o_data_row td:nth(1)').text(), '1200.00',
+ "field should still be formatted based on currency");
+ assert.strictEqual(list.$('tfoot td:nth(1)').text(), '2000.000',
+ "aggregates monetary use digits attribute if available");
+
+ list.destroy();
+ });
+
+ QUnit.test('groups can be sorted on aggregates', async function (assert) {
+ assert.expect(10);
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ arch: '<tree editable="bottom"><field name="foo" /><field name="int_field" sum="Sum"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(args.kwargs.orderby || 'default order');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('tbody .o_list_number').text(), '10517',
+ "initial order should be 10, 5, 17");
+ assert.strictEqual(list.$('tfoot td:last()').text(), '32', "total should be 32");
+
+ await testUtils.dom.click(list.$('.o_column_sortable'));
+ assert.strictEqual(list.$('tfoot td:last()').text(), '32', "total should still be 32");
+ assert.strictEqual(list.$('tbody .o_list_number').text(), '51017',
+ "order should be 5, 10, 17");
+
+ await testUtils.dom.click(list.$('.o_column_sortable'));
+ assert.strictEqual(list.$('tbody .o_list_number').text(), '17105',
+ "initial order should be 17, 10, 5");
+ assert.strictEqual(list.$('tfoot td:last()').text(), '32', "total should still be 32");
+
+ assert.verifySteps(['default order', 'int_field ASC', 'int_field DESC']);
+
+ list.destroy();
+ });
+
+ QUnit.test('groups cannot be sorted on non-aggregable fields', async function (assert) {
+ assert.expect(6);
+ this.data.foo.fields.sort_field = {string: "sortable_field", type: "sting", sortable: true, default: "value"};
+ _.each(this.data.records, function (elem) {
+ elem.sort_field = "value" + elem.id;
+ });
+ this.data.foo.fields.foo.sortable = true;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ arch: '<tree editable="bottom"><field name="foo" /><field name="int_field"/><field name="sort_field"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(args.kwargs.orderby || 'default order');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ //we cannot sort by sort_field since it doesn't have a group_operator
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(2)'));
+ //we can sort by int_field since it has a group_operator
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(1)'));
+ //we keep previous order
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(2)'));
+ //we can sort on foo since we are groupped by foo + previous order
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(0)'));
+
+ assert.verifySteps([
+ 'default order',
+ 'default order',
+ 'int_field ASC',
+ 'int_field ASC',
+ 'foo ASC, int_field ASC'
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('properly apply onchange in simple case', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length + 1000;
+ },
+ };
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ var $foo_td = list.$('td:not(.o_list_record_selector)').first();
+ var $int_field_td = list.$('td:not(.o_list_record_selector)').eq(1);
+
+ assert.strictEqual($int_field_td.text(), '10', "should contain initial value");
+
+ await testUtils.dom.click($foo_td);
+ await testUtils.fields.editInput($foo_td.find('input'), 'tralala');
+
+ assert.strictEqual($int_field_td.find('input').val(), "1007",
+ "should contain input with onchange applied");
+ list.destroy();
+ });
+
+ QUnit.test('column width should not change when switching mode', async function (assert) {
+ assert.expect(4);
+
+ // Warning: this test is css dependant
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="m2o"/>' +
+ '<field name="m2m" widget="many2many_tags"/>' +
+ '</tree>',
+ });
+
+ var startWidths = _.pluck(list.$('thead th'), 'offsetWidth');
+ var startWidth = list.$('table').addBack('table').width();
+
+ // start edition of first row
+ await testUtils.dom.click(list.$('td:not(.o_list_record_selector)').first());
+
+ var editionWidths = _.pluck(list.$('thead th'), 'offsetWidth');
+ var editionWidth = list.$('table').addBack('table').width();
+
+ // leave edition
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ var readonlyWidths = _.pluck(list.$('thead th'), 'offsetWidth');
+ var readonlyWidth = list.$('table').addBack('table').width();
+
+ assert.strictEqual(editionWidth, startWidth,
+ "table should have kept the same width when switching from readonly to edit mode");
+ assert.deepEqual(editionWidths, startWidths,
+ "width of columns should remain unchanged when switching from readonly to edit mode");
+ assert.strictEqual(readonlyWidth, editionWidth,
+ "table should have kept the same width when switching from edit to readonly mode");
+ assert.deepEqual(readonlyWidths, editionWidths,
+ "width of columns should remain unchanged when switching from edit to readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths should depend on the content when there is data', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records[0].foo = 'Some very very long value for a char field';
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="bar"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '</tree>',
+ viewOptions: {
+ limit: 2,
+ },
+ });
+ var widthPage1 = list.$(`th[data-name=foo]`)[0].offsetWidth;
+
+ await cpHelpers.pagerNext(list);
+
+ var widthPage2 = list.$(`th[data-name=foo]`)[0].offsetWidth;
+ assert.ok(widthPage1 > widthPage2,
+ 'column widths should be computed dynamically according to the content');
+
+ list.destroy();
+ });
+
+ QUnit.test('width of some of the fields should be hardcoded if no data', async function (assert) {
+ const assertions = [
+ { field: 'bar', expected: 70, type: 'Boolean' },
+ { field: 'int_field', expected: 74, type: 'Integer' },
+ { field: 'qux', expected: 92, type: 'Float' },
+ { field: 'date', expected: 92, type: 'Date' },
+ { field: 'datetime', expected: 146, type: 'Datetime' },
+ { field: 'amount', expected: 104, type: 'Monetary' },
+ ];
+ assert.expect(9);
+
+ this.data.foo.records = [];
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="bar"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" width="25px"/>' +
+ '</tree>',
+ });
+
+ assert.containsNone(list, '.o_resize', "There shouldn't be any resize handle if no data");
+ assertions.forEach(a => {
+ assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
+ `Field ${a.type} should have a fixed width of ${a.expected} pixels`);
+ });
+ assert.strictEqual(list.$('th[data-name="foo"]')[0].style.width, '100%',
+ "Char field should occupy the remaining space");
+ assert.strictEqual(list.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ 'Currency field should have a fixed width of 25px (see arch)');
+
+ list.destroy();
+ });
+
+ QUnit.test('width of some fields should be hardcoded if no data, and list initially invisible', async function (assert) {
+ const assertions = [
+ { field: 'bar', expected: 70, type: 'Boolean' },
+ { field: 'int_field', expected: 74, type: 'Integer' },
+ { field: 'qux', expected: 92, type: 'Float' },
+ { field: 'date', expected: 92, type: 'Date' },
+ { field: 'datetime', expected: 146, type: 'Datetime' },
+ { field: 'amount', expected: 104, type: 'Monetary' },
+ ];
+ assert.expect(12);
+
+ this.data.foo.fields.foo_o2m = {string: "Foo O2M", type: "one2many", relation: "foo"};
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: `<form>
+ <sheet>
+ <notebook>
+ <page string="Page1"></page>
+ <page string="Page2">
+ <field name="foo_o2m">
+ <tree editable="bottom">
+ <field name="bar"/>
+ <field name="foo"/>
+ <field name="int_field"/>
+ <field name="qux"/>
+ <field name="date"/>
+ <field name="datetime"/>
+ <field name="amount"/>
+ <field name="currency_id" width="25px"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ });
+
+ assert.isNotVisible(form.$('.o_field_one2many'));
+
+ await testUtils.dom.click(form.$('.nav-item:last-child .nav-link'));
+
+ assert.isVisible(form.$('.o_field_one2many'));
+
+ assert.containsNone(form, '.o_field_one2many .o_resize',
+ "There shouldn't be any resize handle if no data");
+ assertions.forEach(a => {
+ assert.strictEqual(form.$(`.o_field_one2many th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
+ `Field ${a.type} should have a fixed width of ${a.expected} pixels`);
+ });
+ assert.strictEqual(form.$('.o_field_one2many th[data-name="foo"]')[0].style.width, '100%',
+ "Char field should occupy the remaining space");
+ assert.strictEqual(form.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ 'Currency field should have a fixed width of 25px (see arch)');
+ assert.strictEqual(form.el.querySelector('.o_list_record_remove_header').style.width, '32px');
+
+ form.destroy();
+ });
+
+ QUnit.test('empty editable list with the handle widget and no content help', async function (assert) {
+ assert.expect(4);
+
+ // no records for the foo model
+ this.data.foo.records = [];
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom">
+ <field name="int_field" widget="handle" />
+ <field name="foo" />
+ </tree>`,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a foo</p>'
+ }
+ },
+ });
+
+ // as help is being provided in the action, table won't be rendered until a record exists
+ assert.containsNone(list, '.o_list_table', " there should not be any records in the view.");
+ assert.containsOnce(list, '.o_view_nocontent', "should have no content help");
+
+ // click on create button
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+ const handleWidgetMinWidth = "33px";
+ const handleWidgetHeader = list.$('thead > tr > th.o_handle_cell');
+ assert.strictEqual(handleWidgetHeader.css('min-width'), handleWidgetMinWidth,
+ "While creating first record, min-width should be applied to handle widget.");
+
+ // creating one record
+ await testUtils.fields.editInput(list.$("tr.o_selected_row input[name='foo']"), 'test_foo');
+ await testUtils.dom.click(list.$('.o_list_button_save'));
+ assert.strictEqual(handleWidgetHeader.css('min-width'), handleWidgetMinWidth,
+ "After creation of the first record, min-width of the handle widget should remain as it is");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: overflowing table', async function (assert) {
+ assert.expect(1);
+
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet: { string: "Beeg char", type: "char", sortable: true },
+ },
+ records: [
+ {
+ id: 1,
+ titi: "Tiny text",
+ grosminet:
+ // Just want to make sure that the table is overflowed
+ `Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Donec est massa, gravida eget dapibus ac, eleifend eget libero.
+ Suspendisse feugiat sed massa eleifend vestibulum. Sed tincidunt
+ velit sed lacinia lacinia. Nunc in fermentum nunc. Vestibulum ante
+ ipsum primis in faucibus orci luctus et ultrices posuere cubilia
+ Curae; Nullam ut nisi a est ornare molestie non vulputate orci.
+ Nunc pharetra porta semper. Mauris dictum eu nulla a pulvinar. Duis
+ eleifend odio id ligula congue sollicitudin. Curabitur quis aliquet
+ nunc, ut aliquet enim. Suspendisse malesuada felis non metus
+ efficitur aliquet.`,
+ },
+ ],
+ };
+ const list = await createView({
+ arch: `
+ <tree editable="top">
+ <field name="titi"/>
+ <field name="grosminet" widget="char"/>
+ </tree>`,
+ data: this.data,
+ model: 'bar',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.$('table').width(), list.$('.o_list_view').width(),
+ "Table should not be stretched by its content");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: overflowing table (3 columns)', async function (assert) {
+ assert.expect(4);
+
+ const longText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Donec est massa, gravida eget dapibus ac, eleifend eget libero.
+ Suspendisse feugiat sed massa eleifend vestibulum. Sed tincidunt
+ velit sed lacinia lacinia. Nunc in fermentum nunc. Vestibulum ante
+ ipsum primis in faucibus orci luctus et ultrices posuere cubilia
+ Curae; Nullam ut nisi a est ornare molestie non vulputate orci.
+ Nunc pharetra porta semper. Mauris dictum eu nulla a pulvinar. Duis
+ eleifend odio id ligula congue sollicitudin. Curabitur quis aliquet
+ nunc, ut aliquet enim. Suspendisse malesuada felis non metus
+ efficitur aliquet.`;
+
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet1: { string: "Beeg char 1", type: "char", sortable: true },
+ grosminet2: { string: "Beeg char 2", type: "char", sortable: true },
+ grosminet3: { string: "Beeg char 3", type: "char", sortable: true },
+ },
+ records: [{
+ id: 1,
+ titi: "Tiny text",
+ grosminet1: longText,
+ grosminet2: longText + longText,
+ grosminet3: longText + longText + longText,
+ }],
+ };
+ const list = await createView({
+ arch: `
+ <tree editable="top">
+ <field name="titi"/>
+ <field name="grosminet1" class="large"/>
+ <field name="grosminet3" class="large"/>
+ <field name="grosminet2" class="large"/>
+ </tree>`,
+ data: this.data,
+ model: 'bar',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.$('table').width(), list.$('.o_list_view').width());
+ const largeCells = list.$('.o_data_cell.large');
+ assert.strictEqual(largeCells[0].offsetWidth, largeCells[1].offsetWidth);
+ assert.strictEqual(largeCells[1].offsetWidth, largeCells[2].offsetWidth);
+ assert.ok(list.$('.o_data_cell:not(.large)')[0].offsetWidth < largeCells[0].offsetWidth);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: list view in an initially unselected notebook page', async function (assert) {
+ assert.expect(5);
+
+ this.data.foo.records = [{ id: 1, o2m: [1] }];
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet: { string: "Beeg char", type: "char", sortable: true },
+ },
+ records: [
+ {
+ id: 1,
+ titi: "Tiny text",
+ grosminet:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+ 'Ut at nisi congue, facilisis neque nec, pulvinar nunc. ' +
+ 'Vivamus ac lectus velit.',
+ },
+ ],
+ };
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="Page1"></page>' +
+ '<page string="Page2">' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="titi"/>' +
+ '<field name="grosminet"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ const [titi, grosminet] = form.el.querySelectorAll('.tab-pane:last-child th');
+ const one2many = form.el.querySelector('.o_field_one2many');
+
+ assert.isNotVisible(one2many,
+ "One2many field should be hidden");
+ assert.strictEqual(titi.style.width, "",
+ "width of small char should not be set yet");
+ assert.strictEqual(grosminet.style.width, "",
+ "width of large char should also not be set");
+
+ await testUtils.dom.click(form.el.querySelector('.nav-item:last-child .nav-link'));
+
+ assert.isVisible(one2many,
+ "One2many field should be visible");
+ assert.ok(
+ titi.style.width.split('px')[0] > 80 &&
+ grosminet.style.width.split('px')[0] > 700,
+ "list has been correctly frozen after being visible");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: list view hidden by an invisible modifier', async function (assert) {
+ assert.expect(5);
+
+ this.data.foo.records = [{ id: 1, bar: true, o2m: [1] }];
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet: { string: "Beeg char", type: "char", sortable: true },
+ },
+ records: [
+ {
+ id: 1,
+ titi: "Tiny text",
+ grosminet:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+ 'Ut at nisi congue, facilisis neque nec, pulvinar nunc. ' +
+ 'Vivamus ac lectus velit.',
+ },
+ ],
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="bar"/>' +
+ '<field name="o2m" attrs="{\'invisible\': [(\'bar\', \'=\', True)]}">' +
+ '<tree editable="bottom">' +
+ '<field name="titi"/>' +
+ '<field name="grosminet"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ const [titi, grosminet] = form.el.querySelectorAll('th');
+ const one2many = form.el.querySelector('.o_field_one2many');
+
+ assert.isNotVisible(one2many,
+ "One2many field should be hidden");
+ assert.strictEqual(titi.style.width, "",
+ "width of small char should not be set yet");
+ assert.strictEqual(grosminet.style.width, "",
+ "width of large char should also not be set");
+
+ await testUtils.dom.click(form.el.querySelector('.o_field_boolean input'));
+
+ assert.isVisible(one2many,
+ "One2many field should be visible");
+ assert.ok(
+ titi.style.width.split('px')[0] > 80 &&
+ grosminet.style.width.split('px')[0] > 700,
+ "list has been correctly frozen after being visible");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: updating list state while invisible', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.onchanges = {
+ bar: function (obj) {
+ obj.o2m = [[5], [0, null, { display_name: "Whatever" }]];
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="bar"/>' +
+ '<notebook>' +
+ '<page string="Page 1"></page>' +
+ '<page string="Page 2">' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ await testUtils.dom.click(form.$('.o_field_boolean input'));
+
+ assert.strictEqual(form.el.querySelector('th').style.width, "",
+ "Column header should be initially unfrozen");
+
+ await testUtils.dom.click(form.$('.nav-item:last() .nav-link'));
+
+ assert.notEqual(form.el.querySelector('th').style.width, "",
+ "Column header should have been frozen");
+
+ form.destroy();
+ });
+
+ QUnit.test('empty list: state with nameless and stringless buttons', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [];
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button string="choucroute"/>
+ <button icon="fa-heart"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.el.querySelector('th[data-name="foo"]').style.width, '50%',
+ "Field column should be frozen");
+ assert.strictEqual(list.el.querySelector('th:last-child').style.width, '50%',
+ "Buttons column should be frozen");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: unnamed columns cannot be resized', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [{ id: 1, o2m: [1] }];
+ this.data.bar.records = [{ id: 1, display_name: "Oui" }];
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<button name="the_button" icon="fa-heart"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ const [charTh, buttonTh] = form.$('.o_field_one2many th');
+ const thRect = charTh.getBoundingClientRect();
+ const resizeRect = charTh.getElementsByClassName('o_resize')[0].getBoundingClientRect();
+
+ assert.strictEqual(thRect.x + thRect.width, resizeRect.x + resizeRect.width,
+ "First resize handle should be attached at the end of the first header");
+ assert.containsNone(buttonTh, '.o_resize',
+ "Columns without name should not have a resize handle");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list view, click on m2o dropdown do not close editable row', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('.o_selected_row .o_data_cell .o_field_many2one input'));
+ const $dropdown = list.$('.o_selected_row .o_data_cell .o_field_many2one input').autocomplete('widget');
+ await testUtils.dom.click($dropdown);
+ assert.containsOnce(list, '.o_selected_row', "should still have editable row");
+
+ await testUtils.dom.click($dropdown.find("li:first"));
+ assert.containsOnce(list, '.o_selected_row', "should still have editable row");
+
+ list.destroy();
+ });
+
+ QUnit.test('width of some of the fields should be hardcoded if no data (grouped case)', async function (assert) {
+ const assertions = [
+ { field: 'bar', expected: 70, type: 'Boolean' },
+ { field: 'int_field', expected: 74, type: 'Integer' },
+ { field: 'qux', expected: 92, type: 'Float' },
+ { field: 'date', expected: 92, type: 'Date' },
+ { field: 'datetime', expected: 146, type: 'Datetime' },
+ { field: 'amount', expected: 104, type: 'Monetary' },
+ ];
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="bar"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" width="25px"/>' +
+ '</tree>',
+ groupBy: ['int_field'],
+ });
+
+ assert.containsNone(list, '.o_resize', "There shouldn't be any resize handle if no data");
+ assertions.forEach(a => {
+ assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
+ `Field ${a.type} should have a fixed width of ${a.expected} pixels`);
+ });
+ assert.strictEqual(list.$('th[data-name="foo"]')[0].style.width, '100%',
+ "Char field should occupy the remaining space");
+ assert.strictEqual(list.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ "Currency field should have a fixed width of 25px (see arch)");
+
+ list.destroy();
+ });
+
+ QUnit.test('column width should depend on the widget', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records = []; // the width heuristic only applies when there are no records
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="datetime" widget="date"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, 92,
+ "should be the optimal width to display a date, not a datetime");
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when adding first record', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = []; // in this scenario, we start with no records
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="datetime"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ var width = list.$('th[data-name="datetime"]')[0].offsetWidth;
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsOnce(list, '.o_data_row');
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when editing a record', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="datetime"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ var width = list.$('th[data-name="datetime"]')[0].offsetWidth;
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ assert.containsOnce(list, '.o_selected_row');
+
+ var longVal = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed blandit, ' +
+ 'justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus ipsum purus ' +
+ 'bibendum est.';
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=text]'), longVal);
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.containsNone(list, '.o_selected_row');
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when switching records in edition', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom">
+ <field name="m2o"/>
+ <field name="text"/>
+ </tree>`,
+ });
+
+ const width = list.$('th[data-name="m2o"]')[0].offsetWidth;
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.strictEqual(list.$('th[data-name="m2o"]')[0].offsetWidth, width);
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(list.$('th[data-name="m2o"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are re-computed on window resize', async function (assert) {
+ assert.expect(2);
+
+ testUtils.mock.patch(ListRenderer, {
+ RESIZE_DELAY: 0,
+ });
+
+ this.data.foo.records[0].text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+ 'Sed blandit, justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus ' +
+ 'ipsum purus bibendum est.';
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom">
+ <field name="datetime"/>
+ <field name="text"/>
+ </tree>`,
+ });
+
+ const initialTextWidth = list.$('th[data-name="text"]')[0].offsetWidth;
+ const selectorWidth = list.$('th.o_list_record_selector')[0].offsetWidth;
+
+ // simulate a window resize
+ list.$el.width(`${list.$el.width() / 2}px`);
+ core.bus.trigger('resize');
+ await testUtils.nextTick();
+
+ const postResizeTextWidth = list.$('th[data-name="text"]')[0].offsetWidth;
+ const postResizeSelectorWidth = list.$('th.o_list_record_selector')[0].offsetWidth;
+ assert.ok(postResizeTextWidth < initialTextWidth);
+ assert.strictEqual(selectorWidth, postResizeSelectorWidth);
+
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('columns with an absolute width are never narrower than that width', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records[0].text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, ' +
+ 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim ' +
+ 'veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo ' +
+ 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum ' +
+ 'dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, ' +
+ 'sunt in culpa qui officia deserunt mollit anim id est laborum';
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="datetime"/>' +
+ '<field name="int_field" width="200px"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, 146);
+ assert.strictEqual(list.$('th[data-name="int_field"]')[0].offsetWidth, 200);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with data: text columns are not crushed', async function (assert) {
+ assert.expect(2);
+
+ const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do ' +
+ 'eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim ' +
+ 'veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo ' +
+ 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum ' +
+ 'dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, ' +
+ 'sunt in culpa qui officia deserunt mollit anim id est laborum';
+ this.data.foo.records[0].foo = longText;
+ this.data.foo.records[0].text = longText;
+ this.data.foo.records[1].foo = "short text";
+ this.data.foo.records[1].text = "short text";
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="text"/></tree>',
+ });
+
+ const fooWidth = list.$('th[data-name="foo"]')[0].offsetWidth;
+ const textWidth = list.$('th[data-name="text"]')[0].offsetWidth;
+ assert.strictEqual(fooWidth, textWidth, "both columns should have been given the same width");
+
+ const firstRowHeight = list.$('.o_data_row:nth(0)')[0].offsetHeight;
+ const secondRowHeight = list.$('.o_data_row:nth(1)')[0].offsetHeight;
+ assert.ok(firstRowHeight > secondRowHeight,
+ "in the first row, the (long) text field should be properly displayed on several lines");
+
+ list.destroy();
+ });
+
+ QUnit.test("button in a list view with a default relative width", async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button name="the_button" icon="fa-heart" width="0.1"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.el.querySelector('.o_data_cell button').style.width, "",
+ "width attribute should not change the CSS style");
+
+ list.destroy();
+ });
+
+ QUnit.test("button columns in a list view don't have a max width", async function (assert) {
+ assert.expect(2);
+
+ testUtils.mock.patch(ListRenderer, {
+ RESIZE_DELAY: 0,
+ });
+
+ // set a long foo value s.t. the column can be squeezed
+ this.data.foo.records[0].foo = 'Lorem ipsum dolor sit amet';
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button name="b1" string="Do This"/>
+ <button name="b2" string="Do That"/>
+ <button name="b3" string="Or Rather Do Something Else"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // simulate a window resize (buttons column width should not be squeezed)
+ list.$el.width('300px');
+ core.bus.trigger('resize');
+ await testUtils.nextTick();
+
+ assert.strictEqual(list.$('th:nth(1)').css('max-width'), '92px',
+ "max-width should be set on column foo to the minimum column width (92px)");
+ assert.strictEqual(list.$('th:nth(2)').css('max-width'), '100%',
+ "no max-width should be harcoded on the buttons column");
+
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when editing multiple records', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="datetime"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ var width = list.$('th[data-name="datetime"]')[0].offsetWidth;
+
+ // select two records and edit
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+
+ assert.containsOnce(list, '.o_selected_row');
+ var longVal = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed blandit, ' +
+ 'justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus ipsum purus ' +
+ 'bibendum est.';
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=text]'), longVal);
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.containsNone(list, '.o_selected_row');
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('row height and width should not change when switching mode', async function (assert) {
+ // Warning: this test is css dependant
+ assert.expect(5);
+
+ var multiLang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ this.data.foo.fields.foo.translate = true;
+ this.data.foo.fields.boolean = {type: 'boolean', string: 'Bool'};
+ var currencies = {};
+ _.each(this.data.res_currency.records, function (currency) {
+ currencies[currency.id] = currency;
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo" required="1"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="boolean"/>' +
+ '<field name="date"/>' +
+ '<field name="text"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" invisible="1"/>' +
+ '<field name="m2o"/>' +
+ '<field name="m2m" widget="many2many_tags"/>' +
+ '</tree>',
+ session: {
+ currencies: currencies,
+ },
+ });
+
+ // the width is hardcoded to make sure we have the same condition
+ // between debug mode and non debug mode
+ list.$el.width('1200px');
+ var startHeight = list.$('.o_data_row:first').outerHeight();
+ var startWidth = list.$('.o_data_row:first').outerWidth();
+
+ // start edition of first row
+ await testUtils.dom.click(list.$('.o_data_row:first > td:not(.o_list_record_selector)').first());
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ var editionHeight = list.$('.o_data_row:first').outerHeight();
+ var editionWidth = list.$('.o_data_row:first').outerWidth();
+
+ // leave edition
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ var readonlyHeight = list.$('.o_data_row:first').outerHeight();
+ var readonlyWidth = list.$('.o_data_row:first').outerWidth();
+
+ assert.strictEqual(startHeight, editionHeight);
+ assert.strictEqual(startHeight, readonlyHeight);
+ assert.strictEqual(startWidth, editionWidth);
+ assert.strictEqual(startWidth, readonlyWidth);
+
+ _t.database.multi_lang = multiLang;
+ list.destroy();
+ });
+
+ QUnit.test('fields are translatable in list view', async function (assert) {
+ assert.expect(3);
+
+ var multiLang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+ this.data.foo.fields.foo.translate = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === "/web/dataset/call_button" && args.method === 'translate_fields') {
+ return Promise.resolve({
+ domain: [],
+ context: {search_default_name: 'foo,foo'},
+ });
+ }
+ if (route === "/web/dataset/call_kw/res.lang/get_installed") {
+ return Promise.resolve([["en_US","English"], ["fr_BE", "Frenglish"]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: '<tree editable="top">' +
+ '<field name="foo" required="1"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:first > td:not(.o_list_record_selector)').first());
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ await testUtils.dom.click(list.$('input.o_field_translate+span.o_field_translate'));
+ await testUtils.nextTick();
+
+ assert.containsOnce($('body'), '.o_translation_dialog');
+ assert.containsN($('.o_translation_dialog'), '.translation>input.o_field_char', 2,
+ 'modal should have 2 languages to translate');
+
+ _t.database.multi_lang = multiLang;
+ list.destroy();
+ });
+
+ QUnit.test('long words in text cells should break into smaller lines', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records[0].text = "a";
+ this.data.foo.records[1].text = "pneumonoultramicroscopicsilicovolcanoconiosis"; // longest english word I could find
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="text"/></tree>',
+ });
+
+ // Intentionally set the table width to a small size
+ list.$('table').width('100px');
+ list.$('th:last').width('100px');
+ var shortText = list.$('.o_data_row:eq(0) td:last')[0].clientHeight;
+ var longText = list.$('.o_data_row:eq(1) td:last')[0].clientHeight;
+ var emptyText = list.$('.o_data_row:eq(2) td:last')[0].clientHeight;
+
+ assert.strictEqual(shortText, emptyText,
+ "Short word should not change the height of the cell");
+ assert.ok(longText > emptyText,
+ "Long word should change the height of the cell");
+
+ list.destroy();
+ });
+
+ QUnit.test('deleting one record', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 4, "should have 4 records");
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ assert.hasClass($('body'),'modal-open', 'body should have modal-open clsss');
+
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+
+ assert.containsN(list, 'tbody td.o_list_record_selector', 3, "should have 3 records");
+ list.destroy();
+ });
+
+ QUnit.test('delete all records matching the domain', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'unlink') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ throw new Error('should not display a notification');
+ },
+ }),
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('delete all records matching the domain (limit reached)', async function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+ this.data.foo.records.push({id: 6, bar: true, foo: "yyy"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'unlink') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ assert.step('notify');
+ },
+ }),
+ },
+ session: {
+ active_ids_limit: 4,
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.verifySteps(['notify']);
+
+ list.destroy();
+ });
+
+ QUnit.test('archiving one record', async function (assert) {
+ assert.expect(12);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true};
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree><field name="foo"/></tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ if (route === '/web/dataset/call_kw/foo/action_archive') {
+ this.data.foo.records[0].active = false;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 4, "should have 4 records");
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-secondary'));
+ assert.containsN(list, 'tbody td.o_list_record_selector', 4, "still should have 4 records");
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+ assert.containsN(list, 'tbody td.o_list_record_selector', 3, "should have 3 records");
+ assert.verifySteps(['/web/dataset/call_kw/foo/action_archive', '/web/dataset/search_read']);
+ list.destroy();
+ });
+
+ QUnit.test('archive all records matching the domain', async function (assert) {
+ assert.expect(6);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true};
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'action_archive') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ throw new Error('should not display a notification');
+ },
+ }),
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('archive all records matching the domain (limit reached)', async function (assert) {
+ assert.expect(8);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true};
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+ this.data.foo.records.push({id: 6, bar: true, foo: "yyy"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'action_archive') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ assert.step('notify');
+ },
+ }),
+ },
+ session: {
+ active_ids_limit: 4,
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.verifySteps(['notify']);
+
+ list.destroy();
+ });
+
+ QUnit.test('archive/unarchive handles returned action', async function (assert) {
+ assert.expect(6);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = { string: 'Active', type: 'boolean', default: true };
+
+ const actionManager = await createActionManager({
+ data: this.data,
+ actions: [{
+ id: 11,
+ name: 'Action 11',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[3, 'list']],
+ search_view_id: [9, 'search'],
+ }],
+ archs: {
+ 'foo,3,list': '<tree><field name="foo"/></tree>',
+ 'foo,9,search': `
+ <search>
+ <filter string="Not Bar" name="not bar" domain="[['bar','=',False]]"/>
+ </search>`,
+ 'bar,false,form': '<form><field name="display_name"/></form>',
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/foo/action_archive') {
+ return Promise.resolve({
+ 'type': 'ir.actions.act_window',
+ 'name': 'Archive Action',
+ 'res_model': 'bar',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'views': [[false, 'form']]
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ do_action: function (ev) {
+ actionManager.doAction(ev.data.action, {});
+ },
+ },
+ });
+
+ await actionManager.doAction(11);
+
+ assert.containsNone(actionManager, '.o_cp_action_menus', 'sidebar should be invisible');
+ assert.containsN(actionManager, 'tbody td.o_list_record_selector', 4, "should have 4 records");
+
+ await testUtils.dom.click(actionManager.$('tbody td.o_list_record_selector:first input'));
+
+ assert.containsOnce(actionManager, '.o_cp_action_menus', 'sidebar should be visible');
+
+ await testUtils.dom.click(actionManager.$('.o_cp_action_menus .o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(actionManager.$('.o_cp_action_menus a:contains(Archive)'));
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+ assert.strictEqual($('.modal').length, 2, 'a confirm modal should be displayed');
+ assert.strictEqual($('.modal:eq(1) .modal-title').text().trim(), 'Archive Action',
+ "action wizard should have been opened");
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('pager (ungrouped and grouped mode), default limit', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.strictEqual(args.limit, 80, "default limit should be 80 in List");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_pager');
+ assert.strictEqual(cpHelpers.getPagerSize(list), "4", "pager's size should be 4");
+ await list.update({ groupBy: ['bar']});
+ assert.strictEqual(cpHelpers.getPagerSize(list), "2", "pager's size should be 2");
+ list.destroy();
+ });
+
+ QUnit.test('can sort records when clicking on header', async function (assert) {
+ assert.expect(9);
+
+ this.data.foo.fields.foo.sortable = true;
+
+ var nbSearchRead = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ mockRPC: function (route) {
+ if (route === '/web/dataset/search_read') {
+ nbSearchRead++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.ok(list.$('tbody tr:first td:contains(yop)').length,
+ "record 1 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(blip)').length,
+ "record 3 should be first");
+
+ nbSearchRead = 0;
+ await testUtils.dom.click(list.$('thead th:contains(Foo)'));
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.ok(list.$('tbody tr:first td:contains(blip)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(yop)').length,
+ "record 1 should be first");
+
+ nbSearchRead = 0;
+ await testUtils.dom.click(list.$('thead th:contains(Foo)'));
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.ok(list.$('tbody tr:first td:contains(yop)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(blip)').length,
+ "record 1 should be first");
+
+ list.destroy();
+ });
+
+ QUnit.test('do not sort records when clicking on header with nolabel', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.fields.foo.sortable = true;
+
+ let nbSearchRead = 0;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" nolabel="1"/><field name="int_field"/></tree>',
+ mockRPC: function (route) {
+ if (route === '/web/dataset/search_read') {
+ nbSearchRead++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.strictEqual(list.$('.o_data_cell').text(), "yop10blip9gnap17blip-4");
+
+ await testUtils.dom.click(list.$('thead th[data-name="int_field"]'));
+ assert.strictEqual(nbSearchRead, 2, "should have done one other search_read");
+ assert.strictEqual(list.$('.o_data_cell').text(), "blip-4blip9yop10gnap17");
+
+ await testUtils.dom.click(list.$('thead th[data-name="foo"]'));
+ assert.strictEqual(nbSearchRead, 2, "shouldn't have done anymore search_read");
+ assert.strictEqual(list.$('.o_data_cell').text(), "blip-4blip9yop10gnap17");
+
+ list.destroy();
+ });
+
+ QUnit.test('use default_order', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree default_order="foo"><field name="foo"/><field name="bar"/></tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.strictEqual(args.sort, 'foo ASC',
+ "should correctly set the sort attribute");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(list.$('tbody tr:first td:contains(blip)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(yop)').length,
+ "record 1 should be first");
+
+ list.destroy();
+ });
+
+ QUnit.test('use more complex default_order', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree default_order="foo, bar desc, int_field">' +
+ '<field name="foo"/><field name="bar"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.strictEqual(args.sort, 'foo ASC, bar DESC, int_field ASC',
+ "should correctly set the sort attribute");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(list.$('tbody tr:first td:contains(blip)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(yop)').length,
+ "record 1 should be first");
+
+ list.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on save', async function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records[0].o2m = [1, 3];
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" default_order="display_name">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+
+ var $o2m = form.$('.o_field_widget[name=o2m]');
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.editInput($o2m.find('.o_field_widget'), "Value 2");
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+ assert.ok(form.$('tbody tr:eq(2) td input').val(),
+ "Value 2 should be third (shouldn't be sorted)");
+
+ await testUtils.form.clickSave(form);
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 2)').length,
+ "Value 2 should be second (should be sorted after saving)");
+ assert.ok(form.$('tbody tr:eq(2) td:contains(Value 3)').length,
+ "Value 3 should be third");
+
+ form.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on demand', async function (assert) {
+ assert.expect(11);
+
+ this.data.foo.records[0].o2m = [1, 3];
+ this.data.bar.fields = {name: {string: "Name", type: "char", sortable: true}};
+ this.data.bar.records[0].name = "Value 1";
+ this.data.bar.records[2].name = "Value 3";
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" default_order="name">' +
+ '<field name="name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+
+ var $o2m = form.$('.o_field_widget[name=o2m]');
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.editInput($o2m.find('.o_field_widget'), "Value 2");
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+ assert.ok(form.$('tbody tr:eq(2) td input').val(),
+ "Value 2 should be third (shouldn't be sorted)");
+
+ await testUtils.dom.click(form.$('.o_form_sheet_bg'));
+
+ await testUtils.dom.click($o2m.find('.o_column_sortable'));
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 1',
+ "Value 1 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(1)').text(), 'Value 2',
+ "Value 2 should be second (should be sorted after saving)");
+ assert.strictEqual(form.$('tbody tr:eq(2)').text(), 'Value 3',
+ "Value 3 should be third");
+
+ await testUtils.dom.click($o2m.find('.o_column_sortable'));
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 3',
+ "Value 3 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(1)').text(), 'Value 2',
+ "Value 2 should be second (should be sorted after saving)");
+ assert.strictEqual(form.$('tbody tr:eq(2)').text(), 'Value 1',
+ "Value 1 should be third");
+
+ form.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on demand in page', async function (assert) {
+ assert.expect(4);
+
+ this.data.bar.fields = {name: {string: "Name", type: "char", sortable: true}};
+
+ var ids = [];
+ for (var i=0; i<45; i++) {
+ var id = 4 + i;
+ ids.push(id);
+ this.data.bar.records.push({
+ id: id,
+ name: "Value " + (id < 10 ? '0' : '') + id,
+ });
+ }
+ this.data.foo.records[0].o2m = ids;
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" default_order="name">' +
+ '<field name="name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await cpHelpers.pagerNext('.o_field_widget[name=o2m]');
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 44',
+ "record 44 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 48',
+ "record 48 should be last");
+
+ await testUtils.dom.click(form.$('.o_column_sortable'));
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 08',
+ "record 48 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 04',
+ "record 44 should be first");
+
+ form.destroy();
+ });
+
+ QUnit.test('can display button in edit mode', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<button name="notafield" type="object" icon="fa-asterisk" class="o_yeah"/>' +
+ '</tree>',
+ });
+ assert.containsN(list, 'tbody button[name=notafield]', 4);
+ assert.containsN(list, 'tbody button[name=notafield].o_yeah', 4, "class o_yeah should be set on the four button");
+ list.destroy();
+ });
+
+ QUnit.test('can display a list with a many2many field', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="m2m"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(route);
+ return this._super(route, args);
+ },
+ });
+ assert.verifySteps(['/web/dataset/search_read'], "should have done 1 search_read");
+ assert.ok(list.$('td:contains(3 records)').length,
+ "should have a td with correct formatted value");
+ list.destroy();
+ });
+
+ QUnit.test('list with group_by_no_leaf flag in context', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ context: {
+ group_by_no_leaf: true,
+ }
+ });
+
+ assert.containsNone(list, '.o_list_buttons', "should not have any buttons");
+ list.destroy();
+ });
+
+ QUnit.test('display a tooltip on a field', async function (assert) {
+ assert.expect(4);
+
+ var initialDebugMode = odoo.debug;
+ odoo.debug = false;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" widget="toggle_button"/>' +
+ '</tree>',
+ });
+
+ // this is done to force the tooltip to show immediately instead of waiting
+ // 1000 ms. not totally academic, but a short test suite is easier to sell :(
+ list.$('th[data-name=foo]').tooltip('show', false);
+
+ list.$('th[data-name=foo]').trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_string').length, 0, "should not have rendered a tooltip");
+
+ odoo.debug = true;
+ // it is necessary to rerender the list so tooltips can be properly created
+ await list.reload();
+ list.$('th[data-name=foo]').tooltip('show', false);
+ list.$('th[data-name=foo]').trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1, "should have rendered a tooltip");
+
+ await list.reload();
+ list.$('th[data-name=bar]').tooltip('show', false);
+ list.$('th[data-name=bar]').trigger($.Event('mouseenter'));
+ assert.containsOnce($, '.oe_tooltip_technical>li[data-item="widget"]',
+ 'widget should be present for this field');
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="widget"]')[0].lastChild.wholeText.trim(),
+ 'Button (toggle_button)', "widget description should be correct");
+
+ odoo.debug = initialDebugMode;
+ list.destroy();
+ });
+
+ QUnit.test('support row decoration', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree decoration-info="int_field > 5">' +
+ '<field name="foo"/><field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody tr.text-info', 3,
+ "should have 3 columns with text-info class");
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ list.destroy();
+ });
+
+ QUnit.test('support row decoration (with unset numeric values)', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" decoration-danger="int_field &lt; 0">' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsNone(list, 'tr.o_data_row.text-danger',
+ "the data row should not have .text-danger decoration (int_field is unset)");
+ await testUtils.fields.editInput(list.$('input[name="int_field"]'), '-3');
+ assert.containsOnce(list, 'tr.o_data_row.text-danger',
+ "the data row should have .text-danger decoration (int_field is negative)");
+ list.destroy();
+ });
+
+ QUnit.test('support row decoration with date', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.records[0].datetime = '2017-02-27 12:51:35';
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree decoration-info="datetime == \'2017-02-27 12:51:35\'" decoration-danger="datetime &gt; \'2017-02-27 12:51:35\' AND datetime &lt; \'2017-02-27 10:51:35\'">' +
+ '<field name="datetime"/><field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsOnce(list, 'tbody tr.text-info',
+ "should have 1 columns with text-info class with good datetime");
+
+ assert.containsNone(list, 'tbody tr.text-danger',
+ "should have 0 columns with text-danger class with wrong timezone datetime");
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ list.destroy();
+ });
+
+ QUnit.test('support field decoration', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="foo" decoration-danger="int_field > 5"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ assert.containsN(list, 'tbody td.o_list_char.text-danger', 3);
+ assert.containsNone(list, 'tbody td.o_list_number.text-danger');
+
+ list.destroy();
+ });
+
+ QUnit.test('bounce create button when no data and click on empty area', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a record</p>'
+ }
+ },
+ });
+
+ assert.containsNone(list, '.o_view_nocontent');
+ await testUtils.dom.click(list.$('.o_list_view'));
+ assert.doesNotHaveClass(list.$('.o_list_button_add'), 'o_catch_attention');
+
+ await list.reload({ domain: [['id', '<', 0]] });
+ assert.containsOnce(list, '.o_view_nocontent');
+ await testUtils.dom.click(list.$('.o_view_nocontent'));
+ assert.hasClass(list.$('.o_list_button_add'), 'o_catch_attention');
+ list.destroy();
+ });
+
+ QUnit.test('no content helper when no data', async function (assert) {
+ assert.expect(5);
+
+ var records = this.data.foo.records;
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ });
+
+ assert.containsOnce(list, '.o_view_nocontent',
+ "should display the no content helper");
+
+ assert.containsNone(list, 'table', "should not have a table in the dom");
+
+ assert.strictEqual(list.$('.o_view_nocontent p.hello:contains(add a partner)').length, 1,
+ "should have rendered no content helper from action");
+
+ this.data.foo.records = records;
+ await list.reload();
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not display the no content helper");
+ assert.containsOnce(list, 'table', "should have a table in the dom");
+ list.destroy();
+ });
+
+ QUnit.test('no nocontent helper when no data and no help', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not display the no content helper");
+
+ assert.containsNone(list, 'tr.o_data_row',
+ "should not have any data row");
+
+ assert.containsOnce(list, 'table', "should have a table in the dom");
+ list.destroy();
+ });
+
+ QUnit.test("empty list with sample data", async function (assert) {
+ assert.expect(19);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ <field name="m2o"/>
+ <field name="m2m" widget="many2many_tags"/>
+ <field name="date"/>
+ <field name="datetime"/>
+ </tree>`,
+ domain: [['id', '<', 0]], // such that no record matches the domain
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ });
+
+ assert.hasClass(list.$el, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help .hello');
+
+ // Check list sample data
+ const firstRow = list.el.querySelector('.o_data_row');
+ const cells = firstRow.querySelectorAll(':scope > .o_data_cell');
+ assert.strictEqual(cells[0].innerText.trim(), "",
+ "Char field should yield an empty element"
+ );
+ assert.containsOnce(cells[1], '.custom-checkbox',
+ "Boolean field has been instantiated"
+ );
+ assert.notOk(isNaN(cells[2].innerText.trim()), "Intger value is a number");
+ assert.ok(cells[3].innerText.trim(), "Many2one field is a string");
+
+ const firstM2MTag = cells[4].querySelector(
+ ':scope span.o_badge_text'
+ ).innerText.trim();
+ assert.ok(firstM2MTag.length > 0, "Many2many contains at least one string tag");
+
+ assert.ok(/\d{2}\/\d{2}\/\d{4}/.test(cells[5].innerText.trim()),
+ "Date field should have the right format"
+ );
+ assert.ok(/\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}/.test(cells[6].innerText.trim()),
+ "Datetime field should have the right format"
+ );
+
+ const textContent = list.$el.text();
+ await list.reload();
+ assert.strictEqual(textContent, list.$el.text(),
+ 'The content should be the same after reloading the view without change'
+ );
+
+ // reload with another domain -> should no longer display the sample records
+ await list.reload({ domain: Domain.FALSE_DOMAIN });
+
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help .hello');
+
+ // reload with another domain matching records
+ await list.reload({ domain: Domain.TRUE_DOMAIN });
+
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_nocontent_help .hello');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty list with sample data: toggle optional field", async function (assert) {
+ assert.expect(9);
+
+ const RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="m2o" optional="hide"/>
+ </tree>`,
+ domain: Domain.FALSE_DOMAIN,
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.hasClass(list.$el, 'o_view_sample_data');
+ assert.ok(list.$('.o_data_row').length > 0);
+ assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled');
+ assert.containsN(list, 'th', 2, "should have 2 th, 1 for selector and 1 for foo");
+ assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle');
+
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+
+ assert.hasClass(list.$el, 'o_view_sample_data');
+ assert.ok(list.$('.o_data_row').length > 0);
+ assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled');
+ assert.containsN(list, 'th', 3);
+
+ list.destroy();
+ });
+
+ QUnit.test("empty list with sample data: keyboard navigation", async function (assert) {
+ assert.expect(11);
+
+ const list = await createView({
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Check keynav is disabled
+ assert.hasClass(
+ list.el.querySelector('.o_data_row'),
+ 'o_sample_data_disabled'
+ );
+ assert.hasClass(
+ list.el.querySelector('.o_list_table > tfoot'),
+ 'o_sample_data_disabled'
+ );
+ assert.hasClass(
+ list.el.querySelector('.o_list_table > thead .o_list_record_selector'),
+ 'o_sample_data_disabled'
+ );
+ assert.containsNone(list.renderer, 'input:not([tabindex="-1"])');
+
+ // From search bar
+ assert.hasClass(document.activeElement, 'o_searchview_input');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'down');
+
+ assert.hasClass(document.activeElement, 'o_searchview_input');
+
+ // From 'Create' button
+ document.querySelector('.btn.o_list_button_add').focus();
+
+ assert.hasClass(document.activeElement, 'o_list_button_add');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'down');
+
+ assert.hasClass(document.activeElement, 'o_list_button_add');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
+
+ assert.containsNone(document.body, '.oe_tooltip_string');
+
+ // From column header
+ list.el.querySelector(':scope th[data-name="foo"]').focus();
+
+ assert.ok(document.activeElement.dataset.name === 'foo');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'down');
+
+ assert.ok(document.activeElement.dataset.name === 'foo');
+
+ list.destroy();
+ });
+
+ QUnit.test("non empty list with sample data", async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ domain: Domain.TRUE_DOMAIN,
+ });
+
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+
+ // reload with another domain matching no record (should not display the sample records)
+ await list.reload({ domain: Domain.FALSE_DOMAIN });
+
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsNone(list, '.o_data_row');
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+
+ list.destroy();
+ });
+
+ QUnit.test('click on header in empty list with sample data', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ domain: Domain.FALSE_DOMAIN,
+ });
+
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+
+ const content = list.$el.text();
+ await testUtils.dom.click(list.$('tr:first .o_column_sortable:first'));
+ assert.strictEqual(list.$el.text(), content, "the content should still be the same");
+
+ list.destroy();
+ });
+
+ QUnit.test("non empty editable list with sample data: delete all records", async function (assert) {
+ assert.expect(7);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.TRUE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ hasActionMenus: true,
+ },
+ });
+
+ // Initial state: all records displayed
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_nocontent_help');
+
+ // Delete all records
+ await testUtils.dom.click(list.el.querySelector('thead .o_list_record_selector input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ // Final state: no more sample data, but nocontent helper displayed
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty editable list with sample data: start create record and cancel", async function (assert) {
+ assert.expect(10);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ },
+ });
+
+ // Initial state: sample data and nocontent helper displayed
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ // Start creating a record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_data_row');
+
+ // Discard temporary record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_discard'));
+
+ // Final state: table should be displayed with no data at all
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsNone(list, '.o_data_row');
+ assert.containsNone(list, '.o_nocontent_help');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty editable list with sample data: create and delete record", async function (assert) {
+ assert.expect(13);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ hasActionMenus: true,
+ },
+ });
+
+ // Initial state: sample data and nocontent helper displayed
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ // Start creating a record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_data_row');
+
+ // Save temporary record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_save'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsOnce(list, '.o_data_row');
+ assert.containsNone(list, '.o_nocontent_help');
+
+ // Delete newly created record
+ await testUtils.dom.click(list.el.querySelector('.o_data_row input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ // Final state: there should be no table, but the no content helper
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help');
+ list.destroy();
+ });
+
+ QUnit.test('Do not display nocontent when it is an empty html tag', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello"></p>'
+ }
+ },
+ });
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not display the no content helper");
+
+ assert.containsOnce(list, 'table', "should have a table in the dom");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button', async function (assert) {
+ assert.expect(14);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<button string="Button 1" type="object" name="button_method"/>' +
+ '</groupby>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ execute_action: function (ev) {
+ assert.deepEqual(ev.data.env.currentID, 2,
+ 'should call with correct id');
+ assert.strictEqual(ev.data.env.model, 'res_currency',
+ 'should call with correct model');
+ assert.strictEqual(ev.data.action_data.name, 'button_method',
+ "should call correct method");
+ assert.strictEqual(ev.data.action_data.type, 'object',
+ 'should have correct type');
+ ev.data.on_success();
+ },
+ },
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsOnce(list, 'thead th:not(.o_list_record_selector)',
+ "there should be only one column");
+
+ await list.update({groupBy: ['currency_id']});
+
+ assert.verifySteps(['web_read_group']);
+ assert.containsN(list, '.o_group_header', 2,
+ "there should be 2 group headers");
+ assert.containsNone(list, '.o_group_header button', 0,
+ "there should be no button in the header");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsOnce(list, '.o_group_header button');
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0) button'));
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button in inner groupbys', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<button string="Button 1" type="object" name="button_method"/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['bar', 'currency_id'],
+ });
+
+ assert.containsN(list, '.o_group_header', 2,
+ "there should be 2 group headers");
+ assert.containsNone(list, '.o_group_header button',
+ "there should be no button in the header");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
+
+ assert.containsN(list, 'tbody:eq(1) .o_group_header', 2,
+ "there should be 2 inner groups header");
+ assert.containsNone(list, 'tbody:eq(1) .o_group_header button',
+ "there should be no button in the header");
+
+ await testUtils.dom.click(list.$('tbody:eq(1) .o_group_header:eq(0)'));
+
+ assert.containsOnce(list, '.o_group_header button',
+ "there should be one button in the header");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button with modifiers', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<field name="position"/>' +
+ '<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
+ '</groupby>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'read' && args.model === 'res_currency') {
+ assert.deepEqual(args.args, [[2, 1], ['position']]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['currency_id'],
+ });
+
+ assert.verifySteps(['web_read_group', 'read']);
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsOnce(list, '.o_group_header button.o_invisible_modifier',
+ "the first group (EUR) should have an invisible button");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)'));
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsN(list, '.o_group_header button', 2,
+ "there should be two buttons (one by header)");
+ assert.doesNotHaveClass(list, '.o_group_header:eq(1) button', 'o_invisible_modifier',
+ "the second header button should be visible");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button with modifiers using a many2one', async function (assert) {
+ assert.expect(5);
+
+ this.data.res_currency.fields.m2o = {string: "Currency M2O", type: "many2one", relation: "bar"};
+ this.data.res_currency.records[0].m2o = 1;
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree expand="1">
+ <field name="foo"/>
+ <groupby name="currency_id">
+ <field name="m2o"/>
+ <button string="Button 1" type="object" name="button_method" attrs='{"invisible": [("m2o", "=", false)]}'/>
+ </groupby>
+ </tree>`,
+ mockRPC(route, args) {
+ assert.step(args.method);
+ return this._super(...arguments);
+ },
+ groupBy: ['currency_id'],
+ });
+
+ assert.containsOnce(list, '.o_group_header:eq(0) button.o_invisible_modifier');
+ assert.containsOnce(list, '.o_group_header:eq(1) button:not(.o_invisible_modifier)');
+
+ assert.verifySteps(['web_read_group', 'read']);
+
+ list.destroy();
+ });
+
+ QUnit.test('reload list view with groupby node', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1">' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<field name="position"/>' +
+ '<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['currency_id'],
+ });
+
+ assert.containsOnce(list, '.o_group_header button:not(.o_invisible_modifier)',
+ "there should be one visible button");
+
+ await list.reload({ domain: [] });
+ assert.containsOnce(list, '.o_group_header button:not(.o_invisible_modifier)',
+ "there should still be one visible button");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view with groupby node and modifiers', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1" editable="bottom">' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<field name="position"/>' +
+ '<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['currency_id'],
+ });
+
+ assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "first row should be in readonly mode");
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "the row should be in edit mode");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+ assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "the row should be back in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with edit button', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1">' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<button string="Button 1" type="edit" name="edit"/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['currency_id'],
+ intercepts: {
+ do_action: function (event) {
+ assert.deepEqual(event.data.action, {
+ context: {create: false},
+ res_id: 2,
+ res_model: 'res_currency',
+ type: 'ir.actions.act_window',
+ views: [[false, 'form']],
+ flags: {mode: 'edit'},
+ }, "should trigger do_action with correct action parameter");
+ }
+ },
+ });
+ await testUtils.dom.click(list.$('.o_group_header:eq(0) button'));
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with subfields, and onchange', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.onchanges = {
+ foo: function () {},
+ };
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom" expand="1">
+ <field name="foo"/>
+ <field name="currency_id"/>
+ <groupby name="currency_id">
+ <field name="position" invisible="1"/>
+ </groupby>
+ </tree>`,
+ groupBy: ['currency_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.deepEqual(args.args[3], {
+ foo: "1",
+ currency_id: "",
+ }, 'onchange spec should not follow relation of many2one fields');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "new value");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view, editable, without data', async function (assert) {
+ assert.expect(12);
+
+ this.data.foo.records = [];
+
+ this.data.foo.fields.date.default = "2017-02-10";
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="date"/>' +
+ '<field name="m2o"/>' +
+ '<field name="foo"/>' +
+ '<button type="object" icon="fa-plus-square" name="method"/>' +
+ '</tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.ok(true, "should have created a record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(list, '.o_view_nocontent',
+ "should have a no content helper displayed");
+
+ assert.containsNone(list, 'div.table-responsive',
+ "should not have a div.table-responsive");
+ assert.containsNone(list, 'table', "should not have rendered a table");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not have a no content helper displayed");
+ assert.containsOnce(list, 'table', "should have rendered a table");
+
+ assert.hasClass(list.$('tbody tr:eq(0)'), 'o_selected_row',
+ "the date field td should be in edit mode");
+ assert.strictEqual(list.$('tbody tr:eq(0) td:eq(1)').text().trim(), "",
+ "the date field td should not have any content");
+
+ assert.strictEqual(list.$('tr.o_selected_row .o_list_record_selector input').prop('disabled'), true,
+ "record selector checkbox should be disabled while the record is not yet created");
+ assert.strictEqual(list.$('.o_list_button button').prop('disabled'), true,
+ "buttons should be disabled while the record is not yet created");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.strictEqual(list.$('tbody tr:eq(0) .o_list_record_selector input').prop('disabled'), false,
+ "record selector checkbox should not be disabled once the record is created");
+ assert.strictEqual(list.$('.o_list_button button').prop('disabled'), false,
+ "buttons should not be disabled once the record is created");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view, editable, with a button', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="foo"/>' +
+ '<button string="abc" icon="fa-phone" type="object" name="schedule_another_phonecall"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsOnce(list, 'table button.o_icon_button i.fa-phone',
+ "should have rendered a button");
+ list.destroy();
+ });
+
+ QUnit.test('list view with a button without icon', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="foo"/>' +
+ '<button string="abc" type="object" name="schedule_another_phonecall"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('table button').first().text(), 'abc',
+ "should have rendered a button with string attribute as label");
+ list.destroy();
+ });
+
+ QUnit.test('list view, editable, can discard', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('td:not(.o_list_record_selector) input').length, 0, "no input should be in the table");
+
+ await testUtils.dom.click(list.$('tbody td:not(.o_list_record_selector):first'));
+ assert.strictEqual(list.$('td:not(.o_list_record_selector) input').length, 1, "first cell should be editable");
+
+ assert.ok(list.$buttons.find('.o_list_button_discard').is(':visible'),
+ "discard button should be visible");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+
+ assert.strictEqual(list.$('td:not(.o_list_record_selector) input').length, 0, "no input should be in the table");
+
+ assert.ok(!list.$buttons.find('.o_list_button_discard').is(':visible'),
+ "discard button should not be visible");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view, click on the list to save', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.date.default = "2017-02-10";
+ this.data.foo.records = [];
+
+ var createCount = 0;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ createCount++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('.o_list_view'));
+
+ assert.strictEqual(createCount, 1, "should have created a record");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('tfoot'));
+
+ assert.strictEqual(createCount, 2, "should have created a record");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('tbody tr').last());
+
+ assert.strictEqual(createCount, 3, "should have created a record");
+ list.destroy();
+ });
+
+ QUnit.test('click on a button in a list view', async function (assert) {
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<button string="a button" name="button_action" icon="fa-car" type="object"/>' +
+ '</tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ execute_action: function (event) {
+ assert.deepEqual(event.data.env.currentID, 1,
+ 'should call with correct id');
+ assert.strictEqual(event.data.env.model, 'foo',
+ 'should call with correct model');
+ assert.strictEqual(event.data.action_data.name, 'button_action',
+ "should call correct method");
+ assert.strictEqual(event.data.action_data.type, 'object',
+ 'should have correct type');
+ event.data.on_closed();
+ },
+ },
+ });
+
+ assert.containsN(list, 'tbody .o_list_button', 4,
+ "there should be one button per row");
+ assert.containsOnce(list, 'tbody .o_list_button:first .o_icon_button .fa.fa-car',
+ 'buttons should have correct icon');
+
+ await testUtils.dom.click(list.$('tbody .o_list_button:first > button'));
+ assert.verifySteps(['/web/dataset/search_read', '/web/dataset/search_read'],
+ "should have reloaded the view (after the action is complete)");
+ list.destroy();
+ });
+
+ QUnit.test('invisible attrs in readonly and editable list', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<button string="a button" name="button_action" icon="fa-car" ' +
+ 'type="object" attrs="{\'invisible\': [(\'id\',\'=\', 1)]}"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="foo" attrs="{\'invisible\': [(\'id\',\'=\', 1)]}"/>' +
+ '</tree>',
+ });
+
+ assert.equal(list.$('tbody tr:nth(0) td:nth(4)').html(), "",
+ "td that contains an invisible field should be empty");
+ assert.hasClass(list.$('tbody tr:nth(0) td:nth(1) button'), "o_invisible_modifier",
+ "button with invisible attrs should be properly hidden");
+
+ // edit first row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2)'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(4) input.o_invisible_modifier').length, 1,
+ "td that contains an invisible field should not be empty in edition");
+ assert.hasClass(list.$('tbody tr:nth(0) td:nth(1) button'), "o_invisible_modifier",
+ "button with invisible attrs should be properly hidden");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+
+ // click on the invisible field's cell to edit first row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(4)'));
+ assert.hasClass(list.$('tbody tr:nth(0)'),'o_selected_row',
+ "first row should be in edition");
+ list.destroy();
+ });
+
+ QUnit.test('monetary fields are properly rendered', async function (assert) {
+ assert.expect(3);
+
+ var currencies = {};
+ _.each(this.data.res_currency.records, function (currency) {
+ currencies[currency.id] = currency;
+ });
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="id"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" invisible="1"/>' +
+ '</tree>',
+ session: {
+ currencies: currencies,
+ },
+ });
+
+ assert.containsN(list, 'tbody tr:first td', 3,
+ "currency_id column should not be in the table");
+ assert.strictEqual(list.$('tbody tr:first td:nth(2)').text().replace(/\s/g, ' '),
+ '1200.00 €', "currency_id column should not be in the table");
+ assert.strictEqual(list.$('tbody tr:nth(1) td:nth(2)').text().replace(/\s/g, ' '),
+ '$ 500.00', "currency_id column should not be in the table");
+
+ list.destroy();
+ });
+
+ QUnit.test('simple list with date and datetime', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="date"/><field name="datetime"/></tree>',
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ });
+
+ assert.strictEqual(list.$('td:eq(1)').text(), "01/25/2017",
+ "should have formatted the date");
+ assert.strictEqual(list.$('td:eq(2)').text(), "12/12/2016 12:55:05",
+ "should have formatted the datetime");
+ list.destroy();
+ });
+
+ QUnit.test('edit a row by clicking on a readonly field', async function (assert) {
+ assert.expect(9);
+
+ this.data.foo.fields.foo.readonly = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ assert.hasClass(list.$('.o_data_row:first td:nth(1)'),'o_readonly_modifier',
+ "foo field cells should have class 'o_readonly_modifier'");
+
+ // edit the first row
+ await testUtils.dom.click(list.$('.o_data_row:first td:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should be selected");
+ var $cell = list.$('.o_data_row:first td:nth(1)');
+ // review
+ assert.hasClass($cell, 'o_readonly_modifier');
+ assert.hasClass($cell.parent(),'o_selected_row');
+ assert.strictEqual(list.$('.o_data_row:first td:nth(1) span').text(), 'yop',
+ "a widget should have been rendered for readonly fields");
+ assert.hasClass(list.$('.o_data_row:first td:nth(2)').parent(),'o_selected_row',
+ "field 'int_field' should be in edition");
+ assert.strictEqual(list.$('.o_data_row:first td:nth(2) input').length, 1,
+ "a widget for field 'int_field should have been rendered'");
+
+ // click again on readonly cell of first line: nothing should have changed
+ await testUtils.dom.click(list.$('.o_data_row:first td:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should be selected");
+ assert.strictEqual(list.$('.o_data_row:first td:nth(2) input').length, 1,
+ "a widget for field 'int_field' should have been rendered (only once)");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with nested groups', async function (assert) {
+ assert.expect(42);
+
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 2});
+
+ var nbRPCs = {readGroup: 0, searchRead: 0};
+ var envIDs = []; // the ids that should be in the environment during this test
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="id"/><field name="int_field"/></tree>',
+ groupBy: ['m2o', 'foo'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ if (args.kwargs.groupby[0] === 'foo') { // nested read_group
+ // called twice (once when opening the group, once when sorting)
+ assert.deepEqual(args.kwargs.domain, [['m2o', '=', 1]],
+ "nested read_group should be called with correct domain");
+ }
+ nbRPCs.readGroup++;
+ } else if (route === '/web/dataset/search_read') {
+ // called twice (once when opening the group, once when sorting)
+ assert.deepEqual(args.domain, [['foo', '=', 'blip'], ['m2o', '=', 1]],
+ "nested search_read should be called with correct domain");
+ nbRPCs.searchRead++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 4,
+ "'switch_view' event has been triggered");
+ },
+ },
+ });
+
+ assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group");
+ assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ // basic rendering tests
+ assert.containsOnce(list, 'tbody', "there should be 1 tbody");
+ assert.containsN(list, '.o_group_header', 2,
+ "should contain 2 groups at first level");
+ assert.strictEqual(list.$('.o_group_name:first').text(), 'Value 1 (4)',
+ "group should have correct name and count");
+ assert.containsN(list, '.o_group_name .fa-caret-right', 2,
+ "the carret of closed groups should be right");
+ assert.strictEqual(list.$('.o_group_name:first span').css('padding-left'),
+ '2px', "groups of level 1 should have a 2px padding-left");
+ assert.strictEqual(list.$('.o_group_header:first td:last').text(), '16',
+ "group aggregates are correctly displayed");
+
+ // open the first group
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group");
+ assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ var $openGroup = list.$('tbody:nth(1)');
+ assert.strictEqual(list.$('.o_group_name:first').text(), 'Value 1 (4)',
+ "group should have correct name and count (of records, not inner subgroups)");
+ assert.containsN(list, 'tbody', 3, "there should be 3 tbodys");
+ assert.containsOnce(list, '.o_group_name:first .fa-caret-down',
+ "the carret of open groups should be down");
+ assert.strictEqual($openGroup.find('.o_group_header').length, 3,
+ "open group should contain 3 groups");
+ assert.strictEqual($openGroup.find('.o_group_name:nth(2)').text(), 'blip (2)',
+ "group should have correct name and count");
+ assert.strictEqual($openGroup.find('.o_group_name:nth(2) span').css('padding-left'),
+ '22px', "groups of level 2 should have a 22px padding-left");
+ assert.strictEqual($openGroup.find('.o_group_header:nth(2) td:last').text(), '-11',
+ "inner group aggregates are correctly displayed");
+
+ // open subgroup
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = [4, 5]; // the opened subgroup contains these two records
+ await testUtils.dom.click($openGroup.find('.o_group_header:nth(2)'));
+ assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
+ assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ var $openSubGroup = list.$('tbody:nth(2)');
+ assert.containsN(list, 'tbody', 4, "there should be 4 tbodys");
+ assert.strictEqual($openSubGroup.find('.o_data_row').length, 2,
+ "open subgroup should contain 2 data rows");
+ assert.strictEqual($openSubGroup.find('.o_data_row:first td:last').text(), '-4',
+ "first record in open subgroup should be res_id 4 (with int_field -4)");
+
+ // open a record (should trigger event 'open_record')
+ await testUtils.dom.click($openSubGroup.find('.o_data_row:first'));
+
+ // sort by int_field (ASC) and check that open groups are still open
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = [5, 4]; // order of the records changed
+ await testUtils.dom.click(list.$('thead th:last'));
+ assert.strictEqual(nbRPCs.readGroup, 2, "should have done two read_groups");
+ assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ $openSubGroup = list.$('tbody:nth(2)');
+ assert.containsN(list, 'tbody', 4, "there should be 4 tbodys");
+ assert.strictEqual($openSubGroup.find('.o_data_row').length, 2,
+ "open subgroup should contain 2 data rows");
+ assert.strictEqual($openSubGroup.find('.o_data_row:first td:last').text(), '-7',
+ "first record in open subgroup should be res_id 5 (with int_field -7)");
+
+ // close first level group
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = []; // the group being closed, there is no more record in the environment
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
+ assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ assert.containsOnce(list, 'tbody', "there should be 1 tbody");
+ assert.containsN(list, '.o_group_header', 2,
+ "should contain 2 groups at first level");
+ assert.containsN(list, '.o_group_name .fa-caret-right', 2,
+ "the carret of closed groups should be right");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list on selection field at level 2', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.fields.priority = {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ };
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="id"/><field name="int_field"/></tree>',
+ groupBy: ['m2o', 'priority'],
+ });
+
+ assert.containsN(list, '.o_group_header', 2,
+ "should contain 2 groups at first level");
+
+ // open the first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ var $openGroup = list.$('tbody:nth(1)');
+ assert.strictEqual($openGroup.find('tr').length, 3,
+ "should have 3 subgroups");
+ assert.strictEqual($openGroup.find('tr').length, 3,
+ "should have 3 subgroups");
+ assert.strictEqual($openGroup.find('.o_group_name:first').text(), 'Low (3)',
+ "should display the selection name in the group header");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with a pager in a group', async function (assert) {
+ assert.expect(6);
+ this.data.foo.records[3].bar = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ viewOptions: {
+ limit: 3,
+ },
+ });
+ var headerHeight = list.$('.o_group_header').css('height');
+
+ // basic rendering checks
+ await testUtils.dom.click(list.$('.o_group_header'));
+ assert.strictEqual(list.$('.o_group_header').css('height'), headerHeight,
+ "height of group header shouldn't have changed");
+ assert.hasClass(list.$('.o_group_header th:eq(1) > nav'), 'o_pager',
+ "last cell of open group header should have classname 'o_pager'");
+
+ assert.strictEqual(cpHelpers.getPagerValue('.o_group_header'), '1-3',
+ "pager's value should be correct");
+ assert.containsN(list, '.o_data_row', 3,
+ "open group should display 3 records");
+
+ // go to next page
+ await cpHelpers.pagerNext('.o_group_header');
+ assert.strictEqual(cpHelpers.getPagerValue('.o_group_header'), '4-4',
+ "pager's value should be correct");
+ assert.containsOnce(list, '.o_data_row',
+ "open group should display 1 record");
+
+ list.destroy();
+ });
+
+ QUnit.test('edition: create new line, then discard', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should have 4 records");
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 1,
+ "create button should be visible");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0,
+ "discard button should be hidden");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 0,
+ "create button should be hidden");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 1,
+ "discard button should be visible");
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should still have 4 records");
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 1,
+ "create button should be visible again");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0,
+ "discard button should be hidden again");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('invisible attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'invisible\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 3,
+ "there should be 3 invisible foo cells in readonly mode");
+
+ // Make first line editable
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(1)'));
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_invisible_modifier').length, 1,
+ "the foo field widget should have been rendered as invisible");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]:not(.o_invisible_modifier)').length, 1,
+ "the foo field widget should have been marked as non-invisible");
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 2,
+ "the foo field widget parent cell should not be invisible anymore");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_invisible_modifier').length, 1,
+ "the foo field widget should have been marked as invisible again");
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 3,
+ "the foo field widget parent cell should now be invisible again");
+
+ // Reswitch the cell to editable and save the row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ await testUtils.dom.click(list.$('thead'));
+
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 2,
+ "there should be 2 invisible foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('readonly attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'readonly\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 3,
+ "there should be 3 readonly foo cells in readonly mode");
+
+ // Make first line editable
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(1)'));
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > span[name="foo"]').length, 1,
+ "the foo field widget should have been rendered as readonly");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as editable");
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 2,
+ "the foo field widget parent cell should not be readonly anymore");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > span[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as readonly");
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 3,
+ "the foo field widget parent cell should now be readonly again");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as editable again");
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 2,
+ "the foo field widget parent cell should not be readonly again");
+
+ // Click outside to leave edition mode
+ await testUtils.dom.click(list.$el);
+
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 2,
+ "there should be 2 readonly foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('required attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody td.o_required_modifier', 3,
+ "there should be 3 required foo cells in readonly mode");
+
+ // Make first line editable
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(1)'));
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_required_modifier').length, 1,
+ "the foo field widget should have been rendered as required");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]:not(.o_required_modifier)').length, 1,
+ "the foo field widget should have been marked as non-required");
+ assert.containsN(list, 'tbody td.o_required_modifier', 2,
+ "the foo field widget parent cell should not be required anymore");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_required_modifier').length, 1,
+ "the foo field widget should have been marked as required again");
+ assert.containsN(list, 'tbody td.o_required_modifier', 3,
+ "the foo field widget parent cell should now be required again");
+
+ // Reswitch the cell to editable and save the row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ await testUtils.dom.click(list.$('thead'));
+
+ assert.containsN(list, 'tbody td.o_required_modifier', 2,
+ "there should be 2 required foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('leaving unvalid rows in edition', async function (assert) {
+ assert.expect(4);
+
+ var warnings = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="bottom">' +
+ '<field name="foo" required="1"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ services: {
+ notification: NotificationService.extend({
+ notify: function (params) {
+ if (params.type === 'danger') {
+ warnings++;
+ }
+ }
+ }),
+ },
+ });
+
+ // Start first line edition
+ var $firstFooTd = list.$('tbody tr:nth(0) td:nth(1)');
+ await testUtils.dom.click($firstFooTd);
+
+ // Remove required foo field value
+ await testUtils.fields.editInput($firstFooTd.find('input'), "");
+
+ // Try starting other line edition
+ var $secondFooTd = list.$('tbody tr:nth(1) td:nth(1)');
+ await testUtils.dom.click($secondFooTd);
+ await testUtils.nextTick();
+
+ assert.strictEqual($firstFooTd.parent('.o_selected_row').length, 1,
+ "first line should still be in edition as invalid");
+ assert.containsOnce(list, 'tbody tr.o_selected_row',
+ "no other line should be in edition");
+ assert.strictEqual($firstFooTd.find('input.o_field_invalid').length, 1,
+ "the required field should be marked as invalid");
+ assert.strictEqual(warnings, 1,
+ "a warning should have been displayed");
+
+ list.destroy();
+ });
+
+ QUnit.test('open a virtual id', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'event',
+ data: this.data,
+ arch: '<tree><field name="name"/></tree>',
+ });
+
+ testUtils.mock.intercept(list, 'switch_view', function (event) {
+ assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), {
+ mode: 'readonly',
+ model: 'event',
+ res_id: '2-20170808020000',
+ view_type: 'form',
+ }, "should trigger a switch_view event to the form view for the record virtual id");
+ });
+ testUtils.dom.click(list.$('td:contains(virtual)'));
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing enter on last line of editable list view', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // click on 3rd line
+ await testUtils.dom.click(list.$('td:contains(gnap)'));
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'),'o_selected_row',
+ "3rd row should be selected");
+
+ // press enter in input
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'enter');
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'),'o_selected_row',
+ "4rd row should be selected");
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row',
+ "3rd row should no longer be selected");
+
+ // press enter on last row
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'enter');
+ assert.containsN(list, 'tr.o_data_row', 5, "should have created a 5th row");
+
+ assert.verifySteps(['/web/dataset/search_read', '/web/dataset/call_kw/foo/onchange']);
+ list.destroy();
+ });
+
+ QUnit.test('pressing tab on last cell of editable list view', async function (assert) {
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field"/></tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('td:contains(blip)').last());
+ assert.strictEqual(document.activeElement.name, "foo",
+ "focus should be on an input with name = foo");
+
+ //it will not create a new line unless a modification is made
+ document.activeElement.value = "blip-changed";
+ $(document.activeElement).trigger({type: 'change'});
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.strictEqual(document.activeElement.name, "int_field",
+ "focus should be on an input with name = int_field");
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(4)'),'o_selected_row',
+ "5th row should be selected");
+ assert.strictEqual(document.activeElement.name, "foo",
+ "focus should be on an input with name = foo");
+
+ assert.verifySteps(['/web/dataset/search_read',
+ '/web/dataset/call_kw/foo/write',
+ '/web/dataset/call_kw/foo/read',
+ '/web/dataset/call_kw/foo/onchange']);
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab and read completes after default_get', async function (assert) {
+ assert.expect(8);
+
+ var onchangeGetPromise = testUtils.makeTestPromise();
+ var readPromise = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'read') {
+ return readPromise.then(function () {
+ return result;
+ });
+ }
+ if (args.method === 'onchange') {
+ return onchangeGetPromise.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ await testUtils.dom.click(list.$('td:contains(-4)').last());
+
+ await testUtils.fields.editInput(list.$('tr.o_selected_row input[name="int_field"]'), '1234');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="int_field"]'), 'tab');
+
+ onchangeGetPromise.resolve();
+ assert.containsN(list, 'tbody tr.o_data_row', 4,
+ "should have 4 data rows");
+
+ readPromise.resolve();
+ await testUtils.nextTick();
+ assert.containsN(list, 'tbody tr.o_data_row', 5,
+ "should have 5 data rows");
+ assert.strictEqual(list.$('td:contains(1234)').length, 1,
+ "should have a cell with new value");
+
+ // we trigger a tab to move to the second cell in the current row. this
+ // operation requires that this.currentRow is properly set in the
+ // list editable renderer.
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(4)'),'o_selected_row',
+ "5th row should be selected");
+
+ assert.verifySteps(['write', 'read', 'onchange']);
+ list.destroy();
+ });
+
+ QUnit.test('display toolbar', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'event',
+ data: this.data,
+ arch: '<tree><field name="name"/></tree>',
+ toolbar: {
+ action: [{
+ model_name: 'event',
+ name: 'Action event',
+ type: 'ir.actions.server',
+ usage: 'ir_actions_server',
+ }],
+ print: [],
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await testUtils.dom.click(list.$('.o_list_record_selector:first input'));
+
+ await cpHelpers.toggleActionMenu(list);
+ assert.deepEqual(cpHelpers.getMenuItemTexts(list), ['Delete', 'Action event']);
+
+ list.destroy();
+ });
+
+ QUnit.test('execute ActionMenus actions with correct params (single page)', async function (assert) {
+ assert.expect(12);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ toolbar: {
+ action: [{
+ id: 44,
+ name: 'Custom Action',
+ type: 'ir.actions.server',
+ }],
+ print: [],
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/action/load') {
+ assert.step(JSON.stringify(args));
+ return Promise.resolve({});
+ }
+ return this._super(...arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ assert.containsN(list, '.o_data_row', 4);
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // unselect first record (will unselect the thead checkbox as well)
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:first input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // add a domain and select first two records
+ await list.reload({ domain: [['bar', '=', true]] });
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:nth(0) input'));
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:nth(1) input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 2);
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ assert.verifySteps([
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3,4],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":2,"active_ids":[2,3,4],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2],"active_model":"foo","active_domain":[["bar","=",true]]}}',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('execute ActionMenus actions with correct params (multi pages)', async function (assert) {
+ assert.expect(13);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ toolbar: {
+ action: [{
+ id: 44,
+ name: 'Custom Action',
+ type: 'ir.actions.server',
+ }],
+ print: [],
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/action/load') {
+ assert.step(JSON.stringify(args));
+ return Promise.resolve({});
+ }
+ return this._super(...arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, '.o_data_row', 2);
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // select all domain
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // add a domain
+ await list.reload({ domain: [['bar', '=', true]] });
+ assert.containsNone(list, '.o_list_selection_box .o_list_select_domain');
+
+ // select all domain
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+ assert.containsNone(list, '.o_list_selection_box .o_list_select_domain');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ assert.verifySteps([
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3,4],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3],"active_model":"foo","active_domain":[["bar","=",true]]}}',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('edit list line after line deletion', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:not(.o_list_record_selector)').first());
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third row should be in edition");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.ok(list.$('.o_data_row:nth(0)').is('.o_selected_row'),
+ "first row should be in edition (creation)");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsNone(list, '.o_selected_row',
+ "no row should be selected");
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:not(.o_list_record_selector)').first());
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third row should be in edition");
+ assert.containsOnce(list, '.o_selected_row',
+ "no other row should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable list with several fields [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:first .o_data_cell:first input')[0]);
+
+ // // Press 'Tab' -> should go to next cell (still in first row)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:first .o_data_cell:last input')[0]);
+
+ // // Press 'Tab' -> should go to next line (first cell)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="int_field"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell:first input')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with several fields [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell:last input')[0]);
+
+ // Press 'shift-Tab' -> should go to previous line (last cell)
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell:first input')[0]);
+
+ // Press 'shift-Tab' -> should go to previous cell
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell:last input')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab and readonly field (no modification)', async function (assert) {
+ // This test makes sure that if we have 2 cells in a row, the first in
+ // edit mode, and the second one readonly, then if we press TAB when the
+ // focus is on the first, then the focus skip the readonly cells and
+ // directly goes to the next line instead.
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ });
+
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)').last());
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'),'o_selected_row',
+ "2nd row should be selected");
+
+ // we do it again. This was broken because the this.currentRow variable
+ // was not properly set, and the second TAB could cause a crash.
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'),'o_selected_row',
+ "3rd row should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab and readonly field (with modification)', async function (assert) {
+ // This test makes sure that if we have 2 cells in a row, the first in
+ // edit mode, and the second one readonly, then if we press TAB when the
+ // focus is on the first, then the focus skips the readonly cells and
+ // directly goes to the next line instead.
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ });
+
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+
+ //modity the cell content
+ testUtils.fields.editAndTrigger($(document.activeElement),
+ 'blip-changed', ['change']);
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'),'o_selected_row',
+ "2nd row should be selected");
+
+ // we do it again. This was broken because the this.currentRow variable
+ // was not properly set, and the second TAB could cause a crash.
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'),'o_selected_row',
+ "3rd row should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab on a list with create="0"', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" create="0">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4,
+ "the list should contain 4 rows");
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'),'o_selected_row',
+ "third row should be in edition");
+
+ // Press 'Tab' -> should go to next line
+ // add a value in the cell because the Tab on an empty first cell would activate the next widget in the view
+ await testUtils.fields.editInput(list.$('.o_selected_row input').eq(1), 11);
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="display_name"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'),'o_selected_row',
+ "fourth row should be in edition");
+
+ // Press 'Tab' -> should go back to first line as the create action isn't available
+ await testUtils.fields.editInput(list.$('.o_selected_row input').eq(1), 11);
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="display_name"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should be in edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab on a one2many list with create="0"', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.records[0].o2m = [1, 2];
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form><sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" create="0">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '<field name="foo"/>' +
+ '</sheet></form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.containsN(form, '.o_field_widget[name=o2m] .o_data_row', 2,
+ "there should be two records in the many2many");
+
+ await testUtils.dom.click(form.$('.o_field_widget[name=o2m] .o_data_cell:first'));
+ assert.hasClass(form.$('.o_field_widget[name=o2m] .o_data_row:first'),'o_selected_row',
+ "first row should be in edition");
+
+ // Press 'Tab' -> should go to next line
+ await testUtils.fields.triggerKeydown(form.$('.o_field_widget[name=o2m] .o_selected_row input'), 'tab');
+ assert.hasClass(form.$('.o_field_widget[name=o2m] .o_data_row:nth(1)'),'o_selected_row',
+ "second row should be in edition");
+
+ // Press 'Tab' -> should get out of the one to many and go to the next field of the form
+ await testUtils.fields.triggerKeydown(form.$('.o_field_widget[name=o2m] .o_selected_row input'), 'tab');
+ // use of owlCompatibilityNextTick because the x2many control panel is updated twice
+ await testUtils.owlCompatibilityNextTick();
+ assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
+ "the next field should be selected");
+
+ form.destroy();
+ });
+
+ QUnit.test('edition, then navigation with tab (with a readonly field)', async function (assert) {
+ // This test makes sure that if we have 2 cells in a row, the first in
+ // edit mode, and the second one readonly, then if we edit and press TAB,
+ // (before debounce), the save operation is properly done (before
+ // selecting the next row)
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ fieldDebounce: 1,
+ });
+
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ await testUtils.fields.editSelect(list.$('tr.o_selected_row input[name="foo"]'), 'new value');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.strictEqual(list.$('tbody tr:first td:contains(new value)').length, 1,
+ "should have the new value visible in dom");
+ assert.verifySteps(["write", "read"]);
+ list.destroy();
+ });
+
+ QUnit.test('edition, then navigation with tab (with a readonly field and onchange)', async function (assert) {
+ // This test makes sure that if we have a read-only cell in a row, in
+ // case the keyboard navigation move over it and there a unsaved changes
+ // (which will trigger an onchange), the focus of the next activable
+ // field will not crash
+ assert.expect(4);
+
+ this.data.bar.onchanges = {
+ o2m: function () {},
+ };
+ this.data.bar.fields.o2m = {string: "O2M field", type: "one2many", relation: "foo"};
+ this.data.bar.records[0].o2m = [1, 4];
+
+ var form = await createView({
+ View: FormView,
+ model: 'bar',
+ res_id: 1,
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="display_name"/>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="date" readonly="1"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.method + ':' + args.model);
+ }
+ return this._super.apply(this, arguments);
+ },
+ fieldDebounce: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var jq_evspecial_focus_trigger = $.event.special.focus.trigger;
+ // As KeyboardEvent will be triggered by JS and not from the
+ // User-Agent itself, the focus event will not trigger default
+ // action (event not being trusted), we need to manually trigger
+ // 'change' event on the currently focused element
+ $.event.special.focus.trigger = function () {
+ if (this !== document.activeElement && this.focus) {
+ var activeElement = document.activeElement;
+ this.focus();
+ $(activeElement).trigger('change');
+ }
+ };
+
+ // editable list, click on first td and press TAB
+ await testUtils.dom.click(form.$('.o_data_cell:contains(yop)'));
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="foo"]')[0],
+ "focus should be on an input with name = foo");
+ await testUtils.fields.editInput(form.$('tr.o_selected_row input[name="foo"]'), 'new value');
+ var tabEvent = $.Event("keydown", { which: $.ui.keyCode.TAB });
+ await testUtils.dom.triggerEvents(form.$('tr.o_selected_row input[name="foo"]'), [tabEvent]);
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="int_field"]')[0],
+ "focus should be on an input with name = int_field");
+
+ // Restore origin jQuery special trigger for 'focus'
+ $.event.special.focus.trigger = jq_evspecial_focus_trigger;
+
+ assert.verifySteps(["onchange:bar"], "onchange method should have been called");
+ form.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with a readonly field [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="qux"/>' +
+ '</tree>',
+ });
+
+ // start on 'qux', line 3
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(2)'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=qux]')[0]);
+
+ // Press 'shift-Tab' -> should go to first cell (same line)
+ $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in first column [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="foo"/>' +
+ '<field name="qux"/>' +
+ '</tree>',
+ });
+
+ // start on 'foo', line 3
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]);
+
+ // Press 'shift-Tab' -> should go to previous line (last cell)
+ $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=qux]')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in last column [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="int_field"/>' +
+ '<field name="foo"/>' +
+ '<field name="qux" readonly="1"/>' +
+ '</tree>',
+ });
+
+ // start on 'int_field', line 3
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=int_field]')[0]);
+
+ // Press 'shift-Tab' -> should go to previous line ('foo' field)
+ $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=foo]')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('skip invisible fields when navigating list view with TAB', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar" invisible="1"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ res_id: 1,
+ });
+
+ await testUtils.dom.click(list.$('td:contains(gnap)'));
+ assert.strictEqual(list.$('input[name="foo"]')[0], document.activeElement,
+ "foo should be focused");
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'tab');
+ assert.strictEqual(list.$('input[name="int_field"]')[0], document.activeElement,
+ "int_field should be focused");
+
+ list.destroy();
+ });
+
+ QUnit.test('skip buttons when navigating list view with TAB (end)', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<button name="kikou" string="Kikou" type="object"/>' +
+ '</tree>',
+ res_id: 1,
+ });
+
+ await testUtils.dom.click(list.$('tbody tr:eq(2) td:eq(1)'));
+ assert.strictEqual(list.$('tbody tr:eq(2) input[name="foo"]')[0], document.activeElement,
+ "foo should be focused");
+ await testUtils.fields.triggerKeydown(list.$('tbody tr:eq(2) input[name="foo"]'), 'tab');
+ assert.strictEqual(list.$('tbody tr:eq(3) input[name="foo"]')[0], document.activeElement,
+ "next line should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('skip buttons when navigating list view with TAB (middle)', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ // Adding a button column makes conversions between column and field position trickier
+ '<button name="kikou" string="Kikou" type="object"/>' +
+ '<field name="foo"/>' +
+ '<button name="kikou" string="Kikou" type="object"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ res_id: 1,
+ });
+
+ await testUtils.dom.click(list.$('tbody tr:eq(2) td:eq(2)'));
+ assert.strictEqual(list.$('tbody tr:eq(2) input[name="foo"]')[0], document.activeElement,
+ "foo should be focused");
+ await testUtils.fields.triggerKeydown(list.$('tbody tr:eq(2) input[name="foo"]'), 'tab');
+ assert.strictEqual(list.$('tbody tr:eq(2) input[name="int_field"]')[0], document.activeElement,
+ "int_field should be focused");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation: not moving down with keydown', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ assert.hasClass(list.$('tr.o_data_row:eq(0)'),'o_selected_row',
+ "1st row should be selected");
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'down');
+ assert.hasClass(list.$('tr.o_data_row:eq(0)'),'o_selected_row',
+ "1st row should still be selected");
+ list.destroy();
+ });
+
+ QUnit.test('navigation: moving right with keydown from text field does not move the focus', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.fields.foo.type = 'text';
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ var textarea = list.$('textarea[name="foo"]')[0];
+ assert.strictEqual(document.activeElement, textarea,
+ "textarea should be focused");
+ assert.strictEqual(textarea.selectionStart, 0,
+ "textarea selection start should be at the beginning");
+ assert.strictEqual(textarea.selectionEnd, 3,
+ "textarea selection end should be at the end");
+ textarea.selectionStart = 3; // Simulate browser keyboard right behavior (unselect)
+ assert.strictEqual(document.activeElement, textarea,
+ "textarea should still be focused");
+ assert.ok(textarea.selectionStart === 3 && textarea.selectionEnd === 3,
+ "textarea value ('yop') should not be selected and cursor should be at the end");
+ await testUtils.fields.triggerKeydown($(textarea), 'right');
+ assert.strictEqual(document.activeElement, list.$('textarea[name="foo"]')[0],
+ "next field (checkbox) should now be focused");
+ list.destroy();
+ });
+
+ QUnit.test('discarding changes in a row properly updates the rendering', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('.o_data_cell:first').text(), "yop",
+ "first cell should contain 'yop'");
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('input[name="foo"]'), "hello");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.strictEqual($('.modal:visible').length, 1,
+ "a modal to ask for discard should be visible");
+
+ await testUtils.dom.click($('.modal:visible .btn-primary'));
+ assert.strictEqual(list.$('.o_data_cell:first').text(), "yop",
+ "first cell should still contain 'yop'");
+
+ list.destroy();
+ });
+
+ QUnit.test('numbers in list are right-aligned', async function (assert) {
+ assert.expect(2);
+
+ var currencies = {};
+ _.each(this.data.res_currency.records, function (currency) {
+ currencies[currency.id] = currency;
+ });
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="qux"/>' +
+ '<field name="amount" widget="monetary"/>' +
+ '<field name="currency_id" invisible="1"/>' +
+ '</tree>',
+ session: {
+ currencies: currencies,
+ },
+ });
+
+ var nbCellRight = _.filter(list.$('.o_data_row:first > .o_data_cell'), function (el) {
+ var style = window.getComputedStyle(el);
+ return style.textAlign === 'right';
+ }).length;
+ assert.strictEqual(nbCellRight, 2,
+ "there should be two right-aligned cells");
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ var nbInputRight = _.filter(list.$('.o_data_row:first > .o_data_cell input'), function (el) {
+ var style = window.getComputedStyle(el);
+ return style.textAlign === 'right';
+ }).length;
+ assert.strictEqual(nbInputRight, 2,
+ "there should be two right-aligned input");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with another grouped list parent, click unfold', async function (assert) {
+ assert.expect(3);
+ this.data.bar.fields = {
+ cornichon: {string: 'cornichon', type: 'char'},
+ };
+
+ var rec = this.data.bar.records[0];
+ // create records to have the search more button
+ var newRecs = [];
+ for (var i=0; i<8; i++) {
+ var newRec = _.extend({}, rec);
+ newRec.id = 1 + i;
+ newRec.cornichon = 'extra fin';
+ newRecs.push(newRec);
+ }
+ this.data.bar.records = newRecs;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="m2o"/></tree>',
+ groupBy: ['bar'],
+ archs: {
+ 'bar,false,list': '<tree><field name="cornichon"/></tree>',
+ 'bar,false,search': '<search><filter context="{\'group_by\': \'cornichon\'}" string="cornichon"/></search>',
+ },
+ });
+
+ await list.update({groupBy: []});
+
+ await testUtils.dom.clickFirst(list.$('.o_data_cell'));
+
+ await testUtils.fields.many2one.searchAndClickItem('m2o', { item: 'Search More' });
+
+ assert.containsOnce($('body'), '.modal-content');
+
+ assert.containsNone($('body'), '.modal-content .o_group_name', 'list in modal not grouped');
+
+ await testUtils.dom.click($('body .modal-content button:contains(Group By)'));
+
+ await testUtils.dom.click($('body .modal-content .o_menu_item a:contains(cornichon)'));
+
+ await testUtils.dom.click($('body .modal-content .o_group_header'));
+
+ assert.containsOnce($('body'), '.modal-content .o_group_open');
+
+ list.destroy();
+ });
+
+ QUnit.test('field values are escaped', async function (assert) {
+ assert.expect(1);
+ var value = '<script>throw Error();</script>';
+
+ this.data.foo.records[0].foo = value;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ });
+
+ assert.strictEqual(list.$('.o_data_cell:first').text(), value,
+ "value should have been escaped");
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ESC discard the current line changes', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.containsN(list, 'tr.o_data_row', 5,
+ "should currently adding a 5th data row");
+
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'escape');
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should have only 4 data row after escape");
+ assert.containsNone(list, 'tr.o_data_row.o_selected_row',
+ "no rows should be selected");
+ assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'),
+ "should not have a visible save button");
+ list.destroy();
+ });
+
+ QUnit.test('pressing ESC discard the current line changes (with required)', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.containsN(list, 'tr.o_data_row', 5,
+ "should currently adding a 5th data row");
+
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'escape');
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should have only 4 data row after escape");
+ assert.containsNone(list, 'tr.o_data_row.o_selected_row',
+ "no rows should be selected");
+ assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'),
+ "should not have a visible save button");
+ list.destroy();
+ });
+
+ QUnit.test('field with password attribute', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" password="True"/></tree>',
+ });
+
+ assert.strictEqual(list.$('td.o_data_cell:eq(0)').text(), '***',
+ "should display string as password");
+ assert.strictEqual(list.$('td.o_data_cell:eq(1)').text(), '****',
+ "should display string as password");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with handle widget', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float" digits="[5,0]"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.strictEqual(args.offset, -4,
+ "should write the sequence starting from the lowest current one");
+ assert.strictEqual(args.field, 'int_field',
+ "should write the right field as sequence");
+ assert.deepEqual(args.ids, [4, 2 , 3],
+ "should write the sequence in correct order");
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "default first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
+ "default second record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
+ "default third record should have amount 300");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
+ "default fourth record should have amount 0");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "new first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
+ "new second record should have amount 0");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
+ "new third record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '300',
+ "new fourth record should have amount 300");
+
+ list.destroy();
+ });
+
+ QUnit.test('result of consecutive resequences is correctly sorted', async function (assert) {
+ assert.expect(9);
+ this.data = { // we want the data to be minimal to have a minimal test
+ foo: {
+ fields: {int_field: {string: "int_field", type: "integer", sortable: true}},
+ records: [
+ {id: 1, int_field: 11},
+ {id: 2, int_field: 12},
+ {id: 3, int_field: 13},
+ {id: 4, int_field: 14},
+ ]
+ }
+ };
+ var moves = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="id"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ if (moves === 0) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [4, 3],
+ offset: 13,
+ field: "int_field",
+ });
+ }
+ if (moves === 1) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [4, 2],
+ offset: 12,
+ field: "int_field",
+ });
+ }
+ if (moves === 2) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [2, 4],
+ offset: 12,
+ field: "int_field",
+ });
+ }
+ if (moves === 3) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [4, 2],
+ offset: 12,
+ field: "int_field",
+ });
+ }
+ moves += 1;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.strictEqual(list.$('tbody tr td.o_list_number').text(), '1234',
+ "default should be sorted by id");
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').eq(2),
+ {position: 'top'}
+ );
+ assert.strictEqual(list.$('tbody tr td.o_list_number').text(), '1243',
+ "the int_field (sequence) should have been correctly updated");
+
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(2),
+ list.$('tbody tr').eq(1),
+ {position: 'top'}
+ );
+ assert.deepEqual(list.$('tbody tr td.o_list_number').text(), '1423',
+ "the int_field (sequence) should have been correctly updated");
+
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(1),
+ list.$('tbody tr').eq(3),
+ {position: 'top'}
+ );
+ assert.deepEqual(list.$('tbody tr td.o_list_number').text(), '1243',
+ "the int_field (sequence) should have been correctly updated");
+
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(2),
+ list.$('tbody tr').eq(1),
+ {position: 'top'}
+ );
+ assert.deepEqual(list.$('tbody tr td.o_list_number').text(), '1423',
+ "the int_field (sequence) should have been correctly updated");
+ list.destroy();
+ });
+
+ QUnit.test('editable list with handle widget', async function (assert) {
+ assert.expect(12);
+
+ // resequence makes sense on a sequence field, not on arbitrary fields
+ this.data.foo.records[0].int_field = 0;
+ this.data.foo.records[1].int_field = 1;
+ this.data.foo.records[2].int_field = 2;
+ this.data.foo.records[3].int_field = 3;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" default_order="int_field">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float" digits="[5,0]"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.strictEqual(args.offset, 1,
+ "should write the sequence starting from the lowest current one");
+ assert.strictEqual(args.field, 'int_field',
+ "should write the right field as sequence");
+ assert.deepEqual(args.ids, [4, 2, 3],
+ "should write the sequence in correct order");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "default first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
+ "default second record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
+ "default third record should have amount 300");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
+ "default fourth record should have amount 0");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "new first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
+ "new second record should have amount 0");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
+ "new third record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '300',
+ "new fourth record should have amount 300");
+
+ await testUtils.dom.click(list.$('tbody tr:eq(1) td:last'));
+
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last input').val(), '0',
+ "the edited record should be the good one");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list, handle widget locks and unlocks on sort', async function (assert) {
+ assert.expect(6);
+
+ // we need another sortable field to lock/unlock the handle
+ this.data.foo.fields.amount.sortable = true;
+ // resequence makes sense on a sequence field, not on arbitrary fields
+ this.data.foo.records[0].int_field = 0;
+ this.data.foo.records[1].int_field = 1;
+ this.data.foo.records[2].int_field = 2;
+ this.data.foo.records[3].int_field = 3;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" default_order="int_field">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.00500.00300.000.00',
+ "default should be sorted by int_field");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ // Handle should be unlocked at this point
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.000.00500.00300.00',
+ "drag and drop should have succeeded, as the handle is unlocked");
+
+ // Sorting by a field different for int_field should lock the handle
+ await testUtils.dom.click(list.$('.o_column_sortable').eq(1));
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '0.00300.00500.001200.00',
+ "should have been sorted by amount");
+
+ // Drag and drop the fourth line in second position (not)
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '0.00300.00500.001200.00',
+ "drag and drop should have failed as the handle is locked");
+
+ // Sorting by int_field should unlock the handle
+ await testUtils.dom.click(list.$('.o_column_sortable').eq(0));
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.000.00500.00300.00',
+ "records should be ordered as per the previous resequence");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.00300.000.00500.00',
+ "drag and drop should have worked as the handle is unlocked");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with handle widget with slow network', async function (assert) {
+ assert.expect(15);
+
+ // resequence makes sense on a sequence field, not on arbitrary fields
+ this.data.foo.records[0].int_field = 0;
+ this.data.foo.records[1].int_field = 1;
+ this.data.foo.records[2].int_field = 2;
+ this.data.foo.records[3].int_field = 3;
+
+ var prom = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float" digits="[5,0]"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ var _super = this._super.bind(this);
+ assert.strictEqual(args.offset, 1,
+ "should write the sequence starting from the lowest current one");
+ assert.strictEqual(args.field, 'int_field',
+ "should write the right field as sequence");
+ assert.deepEqual(args.ids, [4, 2, 3],
+ "should write the sequence in correct order");
+ return prom.then(function () {
+ return _super(route, args);
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "default first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
+ "default second record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
+ "default third record should have amount 300");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
+ "default fourth record should have amount 0");
+
+ // drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ // edit moved row before the end of resequence
+ await testUtils.dom.click(list.$('tbody tr:eq(3) td:last'));
+ await testUtils.nextTick();
+
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').length, 0,
+ "shouldn't edit the line before resequence");
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').length, 1,
+ "should edit the line after resequence");
+
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').val(), '300',
+ "fourth record should have amount 300");
+
+ await testUtils.fields.editInput(list.$('tbody tr:eq(3) td:last input'), 301);
+ await testUtils.dom.click(list.$('tbody tr:eq(0) td:last'));
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
+ "second record should have amount 1");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
+ "third record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '301',
+ "fourth record should have amount 301");
+
+ await testUtils.dom.click(list.$('tbody tr:eq(3) td:last'));
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').val(), '301',
+ "fourth record should have amount 301");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with handle widget, create, move and discard', async function (assert) {
+ // When there are less than 4 records in the table, empty lines are added
+ // to have at least 4 rows. This test ensures that the empty line added
+ // when a new record is discarded is correctly added on the bottom of
+ // the list, even if the discarded record wasn't.
+ assert.expect(11);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="bottom">
+ <field name="int_field" widget="handle"/>
+ <field name="foo" required="1"/>
+ </tree>`,
+ domain: [['bar', '=', false]],
+ });
+
+ assert.containsOnce(list, '.o_data_row');
+ assert.containsN(list, 'tbody tr', 4);
+
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+ assert.containsN(list, '.o_data_row', 2);
+ assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Drag and drop the first line after creating record row
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(0),
+ list.$('tbody tr.o_data_row').eq(1),
+ { position: 'bottom' }
+ );
+ assert.containsN(list, '.o_data_row', 2);
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_list_button_discard'));
+ assert.containsOnce(list, '.o_data_row');
+ assert.hasClass(list.$('tbody tr:first'), 'o_data_row');
+ assert.containsN(list, 'tbody tr', 4);
+
+ list.destroy();
+ });
+
+ QUnit.test('multiple clicks on Add do not create invalid rows', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.onchanges = {
+ m2o: function () {},
+ };
+
+ var prom = testUtils.makeTestPromise();
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="m2o" required="1"/></tree>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return prom.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(list, '.o_data_row', 4,
+ "should contain 4 records");
+
+ // click on Add twice, and delay the onchange
+ testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_data_row', 5,
+ "only one record should have been created");
+
+ list.destroy();
+ });
+
+ QUnit.test('reference field rendering', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.records.push({
+ id: 5,
+ reference: 'res_currency,2',
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="reference"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ assert.step(args.model);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['bar', 'res_currency'], "should have done 1 name_get by model in reference values");
+ assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').text(), "Value 1USDEUREUR",
+ "should have the display_name of the reference");
+ list.destroy();
+ });
+
+ QUnit.test('reference field batched in grouped list', async function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records= [
+ // group 1
+ {id: 1, foo: '1', reference: 'bar,1'},
+ {id: 2, foo: '1', reference: 'bar,2'},
+ {id: 3, foo: '1', reference: 'res_currency,1'},
+ //group 2
+ {id: 4, foo: '2', reference: 'bar,2'},
+ {id: 5, foo: '2', reference: 'bar,3'},
+ ];
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree expand="1">
+ <field name="foo" invisible="1"/>
+ <field name="reference"/>
+ </tree>`,
+ groupBy: ['foo'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'name_get') {
+ if (args.model === 'bar') {
+ assert.deepEqual(args.args[0], [1, 2 ,3]);
+ }
+ if (args.model === "res_currency") {
+ assert.deepEqual(args.args[0], [1]);
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.verifySteps([
+ 'web_read_group',
+ 'name_get',
+ 'name_get',
+ ]);
+ assert.containsN(list, '.o_group_header', 2);
+ const allNames = Array.from(list.el.querySelectorAll('.o_data_cell'), node => node.textContent);
+ assert.deepEqual(allNames, [
+ 'Value 1',
+ 'Value 2',
+ 'USD',
+ 'Value 2',
+ 'Value 3',
+ ]);
+ list.destroy();
+ });
+
+ QUnit.test('multi edit reference field batched in grouped list', async function (assert) {
+ assert.expect(18);
+
+ this.data.foo.records= [
+ // group 1
+ {id: 1, foo: '1', reference: 'bar,1'},
+ {id: 2, foo: '1', reference: 'bar,2'},
+ //group 2
+ {id: 3, foo: '2', reference: 'res_currency,1'},
+ {id: 4, foo: '2', reference: 'bar,2'},
+ {id: 5, foo: '2', reference: 'bar,3'},
+ ];
+ // Field boolean_toggle just to simplify the test flow
+ let nameGetCount = 0;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree expand="1" multi_edit="1">
+ <field name="foo" invisible="1"/>
+ <field name="bar" widget="boolean_toggle"/>
+ <field name="reference"/>
+ </tree>`,
+ groupBy: ['foo'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1,2,3], {bar: true}]);
+ }
+ if (args.method === 'name_get') {
+ if (nameGetCount === 2) {
+ assert.strictEqual(args.model, 'bar');
+ assert.deepEqual(args.args[0], [1,2]);
+ }
+ if (nameGetCount === 3) {
+ assert.strictEqual(args.model, 'res_currency');
+ assert.deepEqual(args.args[0], [1]);
+ }
+ nameGetCount++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps([
+ 'web_read_group',
+ 'name_get',
+ 'name_get',
+ ]);
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')[0]);
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')[1]);
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')[2]);
+ await testUtils.dom.click(list.$('.o_data_row .o_field_boolean')[0]);
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+ assert.containsNone(document.body, '.modal');
+ assert.verifySteps([
+ 'write',
+ 'read',
+ 'name_get',
+ 'name_get',
+ ]);
+ assert.containsN(list, '.o_group_header', 2);
+
+ const allNames = Array.from(list.el.querySelectorAll('.o_data_cell'))
+ .filter(node => !node.children.length).map(n=>n.textContent);
+ assert.deepEqual(allNames, [
+ 'Value 1',
+ 'Value 2',
+ 'USD',
+ 'Value 2',
+ 'Value 3',
+ ]);
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: contexts are correctly sent', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ var context;
+ if (route === '/web/dataset/search_read') {
+ context = args.context;
+ } else {
+ context = args.kwargs.context;
+ }
+ assert.strictEqual(context.active_field, 2, "context should be correct");
+ assert.strictEqual(context.someKey, 'some value', "context should be correct");
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_context: {someKey: 'some value'},
+ },
+ viewOptions: {
+ context: {active_field: 2},
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'abc');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: contexts with multiple edit', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/foo/write' ||
+ route === '/web/dataset/call_kw/foo/read') {
+ var context = args.kwargs.context;
+ assert.strictEqual(context.active_field, 2, "context should be correct");
+ assert.strictEqual(context.someKey, 'some value', "context should be correct");
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_context: {someKey: 'some value'},
+ },
+ viewOptions: {
+ context: {active_field: 2},
+ },
+ });
+
+ // Uses the main selector to select all lines.
+ await testUtils.dom.click(list.$('.o_content input:first'));
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ // Edits first record then confirms changes.
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'legion');
+ await testUtils.dom.click($('.modal-dialog button.btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: single edition with selected records', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `<tree editable="top" multi_edit="1"><field name="foo"/></tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Select first record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+
+ // Edit the second
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:first()'));
+ await testUtils.fields.editInput(list.$('.o_data_row:eq(1) .o_data_cell:first() input'), "oui");
+ await testUtils.dom.click($('.o_list_button_save'));
+
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell:first()').text(), "yop",
+ "First row should remain unchanged");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell:first()').text(), "oui",
+ "Second row should have been updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition', async function (assert) {
+ assert.expect(26);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should write on multi records");
+ } else if (args.method === 'read') {
+ if (args.args[0].length !== 1) {
+ assert.deepEqual(args.args, [[1, 2], ['foo', 'int_field']],
+ "should batch the read");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a line witout modifying a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ assert.hasClass(list.$('.o_data_row:eq(0)'), 'o_selected_row',
+ "the first row should be selected");
+ await testUtils.dom.click('body');
+ assert.containsNone(list, '.o_selected_row', "no row should be selected");
+
+ // create a record and edit its value
+ await testUtils.dom.click($('.o_list_button_add'));
+ assert.verifySteps(['onchange']);
+
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=int_field]'), 123);
+ assert.containsNone(document.body, '.modal', "the multi edition should not be triggered during creation");
+
+ await testUtils.dom.click($('.o_list_button_save'));
+ assert.verifySteps(['create', 'read']);
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ assert.containsOnce(document.body, '.modal', "modal appears when switching cells");
+ await testUtils.dom.click($('.modal .btn:contains(Cancel)'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 2,
+ "Selection should remain unchanged");
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10',
+ "changes have been discarded and row is back to readonly");
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')[0],
+ "focus should be given to the most recently edited cell after discard");
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)'));
+ assert.ok($('.modal').text().includes('those 2 records'), "the number of records should be correctly displayed");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.containsNone(list, '.o_data_cell input.o_field_widget', "no field should be editable anymore");
+ assert.containsNone(list, '.o_list_record_selector input:checked', "no record should be selected anymore");
+ assert.verifySteps(['write', 'read']);
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip666",
+ "the second row should be updated");
+ assert.containsNone(list, '.o_data_cell input.o_field_widget', "no field should be editable anymore");
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')[0],
+ "focus should be given to the most recently edited cell after confirm");
+
+ list.destroy();
+ });
+
+ QUnit.test('create in multi editable list', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ intercepts: {
+ switch_view: function (ev) {
+ assert.strictEqual(ev.data.view_type, 'form');
+ },
+ },
+ });
+
+ // click on CREATE (should trigger a switch_view)
+ await testUtils.dom.click($('.o_list_button_add'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition cannot call onchanges', async function (assert) {
+ assert.expect(15);
+
+ this.data.foo.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length;
+ },
+ };
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ args.args[1].int_field = args.args[1].foo.length;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ // select and edit a single record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'hi');
+
+ assert.containsNone(document.body, '.modal');
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "hi2");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip9");
+
+ assert.verifySteps(['write', 'read']);
+
+ // select the second record (the first one is still selected)
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit foo, first row
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'hello');
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+
+ assert.containsOnce(document.body, '.modal'); // save dialog
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "hello5");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "hello5");
+
+ assert.verifySteps(['write', 'read'], "should not perform the onchange in multi edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition error and cancellation handling', async function (assert) {
+ assert.expect(12);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo" required="1"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a line and cancel
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "abc");
+ await testUtils.dom.click($('.modal .btn:contains("Cancel")'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "first cell should have discarded any change");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // edit a line with an invalid format type
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=int_field]'), "hahaha");
+ assert.containsOnce(document.body, '.modal', "there should be an opened modal");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "changes should be discarded");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // edit a line with an invalid value
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "");
+ assert.containsOnce(document.body, '.modal', "there should be an opened modal");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "changes should be discarded");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('multi edition: many2many_tags in many2many field', async function (assert) {
+ assert.expect(5);
+
+ for (let i = 4; i <= 10; i++) {
+ this.data.bar.records.push({ id: i, display_name: "Value" + i});
+ }
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1"><field name="m2m" widget="many2many_tags"/></tree>',
+ archs: {
+ 'bar,false,list': '<tree><field name="name"/></tree>',
+ 'bar,false,search': '<search></search>',
+ },
+ });
+
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // select two records and enter edit mode
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell'));
+
+ await testUtils.fields.many2one.clickOpenDropdown("m2m");
+ await testUtils.fields.many2one.clickItem("m2m", "Search More");
+ assert.containsOnce(document.body, '.modal .o_list_view', "should have open the modal");
+
+ await testUtils.dom.click($('.modal .o_list_view .o_data_row:first'));
+
+ assert.containsOnce(document.body, ".modal [role='alert']", "should have open the confirmation modal");
+ assert.containsN(document.body, ".modal .o_field_many2manytags .badge", 3);
+ assert.strictEqual($(".modal .o_field_many2manytags .badge:last").text().trim(), "Value 3",
+ "should have display_name in badge");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition of many2one: set same value', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2, 3, 4], { m2o: 1 }],
+ "should force write value on all selected records");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('.o_list_many2one').text(), "Value 1Value 2Value 1Value 1");
+
+ // select all records (the first one has value 1 for m2o)
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ // set m2o to 1 in first record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.many2one.searchAndClickItem('m2o', {search: 'Value 1'});
+
+ assert.containsOnce(document.body, '.modal');
+
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+
+ assert.strictEqual(list.$('.o_list_many2one').text(), "Value 1Value 1Value 1Value 1");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: clicking on "Discard changes" in multi edition', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()'));
+ list.$('.o_data_row:first() .o_data_cell:first() input').val("oof");
+
+ const $discardButton = list.$buttons.find('.o_list_button_discard');
+
+ // Simulates an actual click (event chain is: mousedown > change > blur > focus > mouseup > click)
+ await testUtils.dom.triggerEvents($discardButton, ['mousedown']);
+ await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'),
+ ['change', 'blur', 'focusout']);
+ await testUtils.dom.triggerEvents($discardButton, ['focus']);
+ $discardButton[0].dispatchEvent(new MouseEvent('mouseup'));
+ await testUtils.dom.click($discardButton);
+
+ assert.ok($('.modal').text().includes("Warning"), "Modal should ask to discard changes");
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_row:first() .o_data_cell:first()').text(), "yop");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view (multi edition): mousedown on "Discard", but mouseup somewhere else', async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()'));
+ list.$('.o_data_row:first() .o_data_cell:first() input').val("oof");
+
+ // Simulates a pseudo drag and drop
+ await testUtils.dom.triggerEvents(list.$buttons.find('.o_list_button_discard'), ['mousedown']);
+ await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'),
+ ['change', 'blur', 'focusout']);
+ await testUtils.dom.triggerEvents($(document.body), ['focus']);
+ window.dispatchEvent(new MouseEvent('mouseup'));
+ await testUtils.nextTick();
+
+ assert.ok($('.modal').text().includes("Confirmation"), "Modal should ask to save changes");
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view (multi edition): writable fields in readonly (force save)', async function (assert) {
+ assert.expect(7);
+
+ // boolean toogle widget allows for writing on the record even in readonly mode
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="bar" widget="boolean_toggle"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ mockRPC(route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1,3], {bar: false}]);
+ }
+ return this._super(...arguments);
+ }
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row .o_field_boolean')[0]);
+
+ assert.ok($('.modal').text().includes("Confirmation"), "Modal should ask to save changes");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.verifySteps([
+ 'write',
+ 'read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: click Discard, Cancel discard dialog and then Save in multi edition', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()'));
+ list.$('.o_data_row:first() .o_data_cell:first() input').val("oof");
+
+ const $discardButton = list.$buttons.find('.o_list_button_discard');
+
+ // Simulates an actual click (event chain is: mousedown > change > blur > focus > mouseup > click)
+ await testUtils.dom.triggerEvents($discardButton, ['mousedown']);
+ await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'),
+ ['change', 'blur', 'focusout']);
+ await testUtils.dom.triggerEvents($discardButton, ['focus']);
+ $discardButton[0].dispatchEvent(new MouseEvent('mouseup'));
+ await testUtils.dom.click($discardButton);
+
+ assert.ok($('.modal').text().includes("Warning"), "Modal should ask to discard changes");
+ await testUtils.dom.click($('.modal .btn:contains(Cancel)'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "the first row should still be selected");
+
+ await testUtils.dom.click($('.o_list_button_save'));
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.containsNone(list, '.o_selected_row');
+ assert.strictEqual(list.$('.o_data_row .o_data_cell').text(), "oofoofgnapblip");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition with readonly modifiers', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="id"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" attrs=\'{"readonly": [("id", ">" , 2)]}\'/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should only write on the valid records");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // select all records
+ await testUtils.dom.click(list.$('th.o_list_record_selector input'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+
+ const modalText = $('.modal-body').text()
+ .split(" ").filter(w => w.trim() !== '').join(" ")
+ .split("\n").join('');
+ assert.strictEqual(modalText,
+ "Among the 4 selected records, 2 are valid for this update. Are you sure you want to " +
+ "perform the following update on those 2 records ? Field: int_field Update to: 666");
+ assert.strictEqual(document.querySelector('.modal .o_modal_changes .o_field_widget').style.pointerEvents, 'none',
+ "pointer events should be deactivated on the demo widget");
+
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "1yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "2blip666",
+ "the second row should be updated");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition when the domain is selected', async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree multi_edit="1" limit="2">
+ <field name="id"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ // select all records, and then select all domain
+ await testUtils.dom.click(list.$('th.o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+
+ assert.ok($('.modal-body').text().includes('This update will only consider the records of the current page.'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: many2one with readonly modifier', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch:
+ `<tree editable="top">
+ <field name="m2o" readonly="1"/>
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+
+ assert.containsOnce(list, '.o_data_row:eq(0) .o_data_cell:eq(0) a[name="m2o"]');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1) input')[0],
+ "focus should go to the char input");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition server error handling', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo" required="1"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ return Promise.reject();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a line and confirm
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "abc");
+ await testUtils.dom.click('body');
+ await testUtils.dom.click($('.modal .btn-primary'));
+ // Server error: if there was a crash manager, there would be an open error at this point...
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop',
+ "first cell should have discarded any change");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), 'blip',
+ "second selected record should not have changed");
+ assert.containsNone(list, '.o_data_cell input.o_field_widget',
+ "no field should be editable anymore");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: navigation', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 3,
+ "'switch_view' event has been triggered");
+ },
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select 2 records
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input'));
+
+ // toggle a row mode
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(1)'));
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected");
+
+ // Keyboard navigation only interracts with selected elements
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input.o_field_widget[name="int_field"]'), 'enter');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected again");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected again");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_data_cell input.o_field_widget',
+ "no row should be editable anymore");
+ // Clicking on an unselected record while no row is being edited will open the record (switch_view)
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition: edit and validate last row', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ // in this test, we want to accurately mock what really happens, that is, input
+ // fields only trigger their changes on 'change' event, not on 'input'
+ fieldDebounce: 100000,
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+
+ // select all records
+ await testUtils.dom.click(list.$('.o_list_view thead .o_list_record_selector input'));
+
+ // edit last cell of last line
+ await testUtils.dom.click(list.$('.o_data_row:last .o_data_cell:last'));
+ testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), '666');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_data_cell:last input'), 'enter');
+
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.containsN(list, '.o_data_row', 4,
+ "should not create a new row as we were in multi edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: navigation in grouped list', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ groupBy: ['bar'],
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 3,
+ "'switch_view' event has been triggered");
+ },
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Open both groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_group_header:last'));
+
+ // select 2 records
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input'));
+
+ // toggle a row mode
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)'));
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected");
+
+ // Keyboard navigation only interracts with selected elements
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input.o_field_widget'), 'enter');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected again");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected again");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_data_cell input.o_field_widget', "no row should be editable anymore");
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: single edition does not behave like a multi-edition', async function (assert) {
+ assert.expect(3);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo" required="1"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+
+ // edit a field (invalid input)
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "");
+
+ assert.containsOnce($('body'),'.modal', "should have a modal (invalid fields)");
+ await testUtils.dom.click($('.modal button.btn'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "bar");
+
+ assert.containsNone($('body'),'.modal', "should not have a modal");
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "bar",
+ "the first row should be updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: multi edition', async function (assert) {
+ assert.expect(14);
+
+ const list = await createView({
+ arch:
+ `<tree multi_edit="1">
+ <field name="foo"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should write on multi records");
+ } else if (args.method === 'read') {
+ if (args.args[0].length !== 1) {
+ assert.deepEqual(args.args, [[1, 2], ['foo', 'int_field']],
+ "should batch the read");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+
+ assert.containsOnce(document.body, '.modal',
+ "modal appears when switching cells");
+
+ await testUtils.dom.click($('.modal .btn:contains(Cancel)'));
+
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10',
+ "changes have been discarded and row is back to readonly");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)'));
+
+ assert.containsOnce(document.body, '.modal',
+ "there should be an opened modal");
+ assert.ok($('.modal').text().includes('those 2 records'),
+ "the number of records should be correctly displayed");
+
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.verifySteps(['write', 'read']);
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip666",
+ "the second row should be updated");
+ assert.containsNone(list, '.o_data_cell input.o_field_widget',
+ "no field should be editable anymore");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: m2m tags in grouped list', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="bar"/>
+ <field name="m2m" widget="many2many_tags"/>
+ </tree>`,
+ data: this.data,
+ groupBy: ['bar'],
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Opens first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ assert.notEqual(list.$('.o_data_row:first').text(), list.$('.o_data_row:last').text(),
+ "First row and last row should have different values");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector:first input'));
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:eq(1)'));
+ await testUtils.dom.click(list.$('.o_selected_row .o_field_many2manytags .o_delete:first'));
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_row:first').text(), list.$('.o_data_row:last').text(),
+ "All rows should have been correctly updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: edit many2one from external link', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="m2o"/>
+ </tree>`,
+ archs: {
+ 'bar,false,form': '<form string="Bar"><field name="display_name"/></form>',
+ },
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector:first input'));
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:eq(0)'));
+ await testUtils.dom.click(list.$('.o_external_button:first'));
+
+ // Change the M2O value in the Form dialog
+ await testUtils.fields.editInput($('.modal input:first'), "OOF");
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual($('.modal .o_field_widget[name=m2o]').text(), "OOF",
+ "Value of the m2o should be updated in the confirmation dialog");
+
+ // Close the confirmation dialog
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_cell:first').text(), "OOF",
+ "Value of the m2o should be updated in the list");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with fields with readonly modifier', async function (assert) {
+ assert.expect(8);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="top">
+ <field name="bar"/>
+ <field name="foo" attrs="{'readonly': [['bar','=',True]]}"/>
+ <field name="m2o" attrs="{'readonly': [['bar','=',False]]}"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+
+ assert.containsOnce(list, '.o_selected_row');
+ assert.notOk(list.$('.o_selected_row .o_field_boolean input').is(':checked'));
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_list_char'), 'o_readonly_modifier');
+ assert.hasClass(list.$('.o_selected_row .o_list_many2one'), 'o_readonly_modifier');
+
+ await testUtils.dom.click(list.$('.o_selected_row .o_field_boolean input'));
+
+ assert.ok(list.$('.o_selected_row .o_field_boolean input').is(':checked'));
+ assert.hasClass(list.$('.o_selected_row .o_list_char'), 'o_readonly_modifier');
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_list_many2one'), 'o_readonly_modifier');
+
+ await testUtils.dom.click(list.$('.o_selected_row .o_field_many2one input'));
+
+ assert.strictEqual(document.activeElement, list.$('.o_selected_row .o_field_many2one input')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with many2one: click out does not discard the row', async function (assert) {
+ // In this test, we simulate a long click by manually triggering a mousedown and later on
+ // mouseup and click events
+ assert.expect(5);
+
+ this.data.bar.fields.m2o = {string: "M2O field", type: "many2one", relation: "foo"};
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="display_name"/>
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="m2o" required="1"/>
+ </tree>
+ </field>
+ </form>`,
+ });
+
+ assert.containsNone(form, '.o_data_row');
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add > a'));
+ assert.containsOnce(form, '.o_data_row');
+
+ // focus and write something in the m2o
+ form.$('.o_field_many2one input').focus().val('abcdef').trigger('keyup');
+ await testUtils.nextTick();
+
+ // then simulate a mousedown outside
+ form.$('.o_field_widget[name="display_name"]').focus().trigger('mousedown');
+ await testUtils.nextTick();
+ assert.containsOnce(document.body, '.modal', "should ask confirmation to create a record");
+
+ // trigger the mouseup and the click
+ form.$('.o_field_widget[name="display_name"]').trigger('mouseup').trigger('click');
+ await testUtils.nextTick();
+
+ assert.containsOnce(document.body, '.modal', "modal should still be displayed");
+ assert.containsOnce(form, '.o_data_row', "the row should still be there");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list alongside html field: click out to unselect the row', async function (assert) {
+ assert.expect(5);
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="text" widget="html"/>
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ </tree>
+ </field>
+ </form>`,
+ });
+
+ assert.containsNone(form, '.o_data_row');
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add > a'));
+ assert.containsOnce(form, '.o_data_row');
+ assert.hasClass(form.$('.o_data_row'), 'o_selected_row');
+
+ // click outside to unselect the row
+ await testUtils.dom.click(document.body);
+ assert.containsOnce(form, '.o_data_row');
+ assert.doesNotHaveClass(form.$('.o_data_row'), 'o_selected_row');
+
+ form.destroy();
+ });
+
+ QUnit.test('list grouped by date:month', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="date"/></tree>',
+ groupBy: ['date:month'],
+ });
+
+ assert.strictEqual(list.$('tbody').text(), "January 2017 (1)Undefined (3)",
+ "the group names should be correct");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list edition with toggle_button widget', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="bar" widget="toggle_button"/></tree>',
+ groupBy: ['m2o'],
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {bar: false},
+ "should write the correct value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsOnce(list, '.o_data_row:first .o_toggle_button_success',
+ "boolean value of the first record should be true");
+ await testUtils.dom.click(list.$('.o_data_row:first .o_icon_button'));
+ assert.strictEqual(list.$('.o_data_row:first .text-muted:not(.o_toggle_button_success)').length, 1,
+ "boolean button should have been updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list view, indentation for empty group', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.priority = {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ };
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="id"/></tree>',
+ groupBy: ['priority', 'm2o'],
+ mockRPC: function (route, args) {
+ // Override of the read_group to display the row even if there is no record in it,
+ // to mock the behavihour of some fields e.g stage_id on the sale order.
+ if (args.method === 'web_read_group' && args.kwargs.groupby[0] === "m2o") {
+ return Promise.resolve({
+ groups: [{
+ id: 8,
+ m2o: [1, "Value 1"],
+ m2o_count: 0
+ }, {
+ id: 2,
+ m2o: [2, "Value 2"],
+ m2o_count: 1
+ }],
+ length: 1,
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // open the first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.strictEqual(list.$('th.o_group_name').eq(1).children().length, 1,
+ "There should be an empty element creating the indentation for the subgroup.");
+ assert.hasClass(list.$('th.o_group_name').eq(1).children().eq(0), 'fa',
+ "The first element of the row name should have the fa class");
+ assert.strictEqual(list.$('th.o_group_name').eq(1).children().eq(0).is('span'), true,
+ "The first element of the row name should be a span");
+ list.destroy();
+ });
+
+ QUnit.test('basic support for widgets', async function (assert) {
+ assert.expect(1);
+
+ var MyWidget = Widget.extend({
+ init: function (parent, dataPoint) {
+ this.data = dataPoint.data;
+ },
+ start: function () {
+ this.$el.text(JSON.stringify(this.data));
+ },
+ });
+ widgetRegistry.add('test', MyWidget);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="int_field"/><widget name="test"/></tree>',
+ });
+
+ assert.strictEqual(list.$('.o_widget').first().text(), '{"foo":"yop","int_field":10,"id":1}',
+ "widget should have been instantiated");
+
+ list.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('use the limit attribute in arch', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.limit, 2,
+ 'should use the correct limit value');
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-2');
+ assert.strictEqual(cpHelpers.getPagerSize(list), '4');
+
+ assert.containsN(list, '.o_data_row', 2,
+ 'should display 2 data rows');
+ list.destroy();
+ });
+
+ QUnit.test('check if the view destroys all widgets and instances', async function (assert) {
+ assert.expect(2);
+
+ var instanceNumber = 0;
+ testUtils.mock.patch(mixins.ParentedMixin, {
+ init: function () {
+ instanceNumber++;
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ if (!this.isDestroyed()) {
+ instanceNumber--;
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ var params = {
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="date"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="m2o"/>' +
+ '<field name="o2m"/>' +
+ '<field name="m2m"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id"/>' +
+ '<field name="datetime"/>' +
+ '<field name="reference"/>' +
+ '</tree>',
+ };
+
+ var list = await createView(params);
+ assert.ok(instanceNumber > 0);
+
+ list.destroy();
+ assert.strictEqual(instanceNumber, 0);
+
+ testUtils.mock.unpatch(mixins.ParentedMixin);
+ });
+
+ QUnit.test('concurrent reloads finishing in inverse order', async function (assert) {
+ assert.expect(4);
+
+ var blockSearchRead = false;
+ var prom = testUtils.makeTestPromise();
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ mockRPC: function (route) {
+ var result = this._super.apply(this, arguments);
+ if (route === '/web/dataset/search_read' && blockSearchRead) {
+ return prom.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should contain 4 records");
+
+ // reload with a domain (this request is blocked)
+ blockSearchRead = true;
+ list.reload({domain: [['foo', '=', 'yop']]});
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should still contain 4 records (search_read being blocked)");
+
+ // reload without the domain
+ blockSearchRead = false;
+ list.reload({domain: []});
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should still contain 4 records");
+
+ // unblock the RPC
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should still contain 4 records");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view on a "noCache" model', async function (assert) {
+ assert.expect(9);
+
+ testUtils.mock.patch(BasicModel, {
+ noCacheModels: BasicModel.prototype.noCacheModels.concat(['foo']),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (_.contains(['create', 'unlink', 'write'], args.method)) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+ core.bus.on('clear_cache', list, assert.step.bind(assert, 'clear_cache'));
+
+ // create a new record
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget'), 'some value');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ // edit an existing record
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget'), 'new value');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ // delete a record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.verifySteps([
+ 'create',
+ 'clear_cache',
+ 'write',
+ 'clear_cache',
+ 'unlink',
+ 'clear_cache',
+ ]);
+
+ list.destroy();
+ testUtils.mock.unpatch(BasicModel);
+
+ assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy
+ });
+
+ QUnit.test('list view move to previous page when all records from last page deleted', async function (assert) {
+ assert.expect(5);
+
+ let checkSearchRead = false;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (checkSearchRead && route === '/web/dataset/search_read') {
+ assert.strictEqual(args.limit, 3, "limit should 3");
+ assert.notOk(args.offset, "offset should not be passed i.e. offset 0 by default");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 4',
+ "should have 2 pages and current page should be first page");
+
+ // move to next page
+ await testUtils.dom.click(list.$('.o_pager_next'));
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '4-4 / 4',
+ "should be on second page");
+
+ // delete a record
+ await testUtils.dom.click(list.$('tbody .o_data_row:first td.o_list_record_selector:first input'));
+ checkSearchRead = true;
+ await testUtils.dom.click(list.$('.o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(list.$('a:contains(Delete)'));
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 3',
+ "should have 1 page only");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list view move to previous page of group when all records from last page deleted', async function (assert) {
+ assert.expect(7);
+
+ let checkSearchRead = false;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (checkSearchRead && route === '/web/dataset/search_read') {
+ assert.strictEqual(args.limit, 2, "limit should 2");
+ assert.notOk(args.offset, "offset should not be passed i.e. offset 0 by default");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ groupBy: ['m2o'],
+ });
+
+ assert.strictEqual(list.$('th:contains(Value 1 (3))').length, 1,
+ "Value 1 should contain 3 records");
+ assert.strictEqual(list.$('th:contains(Value 2 (1))').length, 1,
+ "Value 2 should contain 1 record");
+
+ await testUtils.dom.click(list.$('th.o_group_name:nth(0)'));
+ assert.strictEqual(list.$('th.o_group_name:eq(0) .o_pager_counter').text().trim(), '1-2 / 3',
+ "should have 2 pages and current page should be first page");
+
+ // move to next page
+ await testUtils.dom.click(list.$('.o_group_header .o_pager_next'));
+ assert.strictEqual(list.$('th.o_group_name:eq(0) .o_pager_counter').text().trim(), '3-3 / 3',
+ "should be on second page");
+
+ // delete a record
+ await testUtils.dom.click(list.$('tbody .o_data_row:first td.o_list_record_selector:first input'));
+ checkSearchRead = true;
+ await testUtils.dom.click(list.$('.o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(list.$('a:contains(Delete)'));
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+
+ assert.strictEqual(list.$('th.o_group_name:eq(0) .o_pager_counter').text().trim(), '',
+ "should be on first page now");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view move to previous page when all records from last page archive/unarchived', async function (assert) {
+ assert.expect(9);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = { string: 'Active', type: 'boolean', default: true };
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3"><field name="display_name"/></tree>',
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/foo/action_archive') {
+ this.data.foo.records[3].active = false;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 4',
+ "should have 2 pages and current page should be first page");
+ assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 3,
+ "should have 3 records");
+
+ // move to next page
+ await testUtils.dom.click(list.$('.o_pager_next'));
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '4-4 / 4',
+ "should be on second page");
+ assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 1,
+ "should have 1 records");
+ assert.containsNone(list, '.o_cp_action_menus', 'sidebar should not be available');
+
+ await testUtils.dom.click(list.$('tbody .o_data_row:first td.o_list_record_selector:first input'));
+ assert.containsOnce(list, '.o_cp_action_menus', 'sidebar should be available');
+
+ // archive all records of current page
+ await testUtils.dom.click(list.$('.o_cp_action_menus .o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(list.$('a:contains(Archive)'));
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+ assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 3,
+ "should have 3 records");
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 3',
+ "should have 1 page only");
+
+ list.destroy();
+ });
+
+ QUnit.test('list should ask to scroll to top on page changes', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ intercepts: {
+ scrollTo: function (ev) {
+ assert.strictEqual(ev.data.top, 0,
+ "should ask to scroll to top");
+ assert.step('scroll');
+ },
+ },
+ });
+
+
+ // switch pages (should ask to scroll)
+ await cpHelpers.pagerNext(list);
+ await cpHelpers.pagerPrevious(list);
+ assert.verifySteps(['scroll', 'scroll'],
+ "should ask to scroll when switching pages");
+
+ // change the limit (should not ask to scroll)
+ await cpHelpers.setPagerValue(list, '1-2');
+ await testUtils.nextTick();
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-2');
+ assert.verifySteps([], "should not ask to scroll when changing the limit");
+
+ // switch pages again (should still ask to scroll)
+ await cpHelpers.pagerNext(list);
+
+ assert.verifySteps(['scroll'], "this is still working after a limit change");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with handle field, override default_get, bottom when inline', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.fields.int_field.default = 10;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="bottom" default_order="int_field">'
+ + '<field name="int_field" widget="handle"/>'
+ + '<field name="foo"/>'
+ +'</tree>',
+ });
+
+ // starting condition
+ assert.strictEqual($('.o_data_cell').text(), "blipblipyopgnap");
+
+ // click add a new line
+ // save the record
+ // check line is at the correct place
+
+ var inputText = 'ninja';
+ await testUtils.dom.click($('.o_list_button_add'));
+ await testUtils.fields.editInput(list.$('.o_input[name="foo"]'), inputText);
+ await testUtils.dom.click($('.o_list_button_save'));
+ await testUtils.dom.click($('.o_list_button_add'));
+
+ assert.strictEqual($('.o_data_cell').text(), "blipblipyopgnap" + inputText);
+
+ list.destroy();
+ });
+
+ QUnit.test('create record on list with modifiers depending on id', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="id" invisible="1"/>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'id\',\'!=\',False]]}"/>' +
+ '<field name="int_field" attrs="{\'invisible\': [[\'id\',\'!=\',False]]}"/>' +
+ '</tree>',
+ });
+
+ // add a new record
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ // modifiers should be evaluted to false
+ assert.containsOnce(list, '.o_selected_row');
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier');
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier');
+
+ // set a value and save
+ await testUtils.fields.editInput(list.$('.o_selected_row input[name=foo]'), 'some value');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ // modifiers should be evaluted to true
+ assert.hasClass(list.$('.o_data_row:first .o_data_cell:first'), 'o_readonly_modifier');
+ assert.hasClass(list.$('.o_data_row:first .o_data_cell:nth(1)'), 'o_invisible_modifier');
+
+ // edit again the just created record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+
+ // modifiers should be evaluted to true
+ assert.containsOnce(list, '.o_selected_row');
+ assert.hasClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier');
+ assert.hasClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier');
+
+ list.destroy();
+ });
+
+ QUnit.test('readonly boolean in editable list is readonly', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar" attrs="{\'readonly\': [(\'foo\', \'!=\', \'yop\')]}"/>' +
+ '</tree>',
+ });
+
+ // clicking on disabled checkbox with active row does not work
+ var $disabledCell = list.$('.o_data_row:eq(1) .o_data_cell:last-child');
+ await testUtils.dom.click($disabledCell.prev());
+ assert.containsOnce($disabledCell, ':disabled:checked');
+ var $disabledLabel = $disabledCell.find('.custom-control-label');
+ await testUtils.dom.click($disabledLabel);
+ assert.containsOnce($disabledCell, ':checked',
+ "clicking disabled checkbox did not work"
+ );
+ assert.ok(
+ $(document.activeElement).is('input[type="text"]'),
+ "disabled checkbox is not focused after click"
+ );
+
+ // clicking on enabled checkbox with active row toggles check mark
+ var $enabledCell = list.$('.o_data_row:eq(0) .o_data_cell:last-child');
+ await testUtils.dom.click($enabledCell.prev());
+ assert.containsOnce($enabledCell, ':checked:not(:disabled)');
+ var $enabledLabel = $enabledCell.find('.custom-control-label');
+ await testUtils.dom.click($enabledLabel);
+ assert.containsNone($enabledCell, ':checked',
+ "clicking enabled checkbox worked and unchecked it"
+ );
+ assert.ok(
+ $(document.activeElement).is('input[type="checkbox"]'),
+ "enabled checkbox is focused after click"
+ );
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with async widget', async function (assert) {
+ assert.expect(4);
+
+ var prom = testUtils.makeTestPromise();
+ var AsyncWidget = Widget.extend({
+ willStart: function () {
+ return prom;
+ },
+ start: function () {
+ this.$el.text('ready');
+ },
+ });
+ widgetRegistry.add('asyncWidget', AsyncWidget);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><widget name="asyncWidget"/></tree>',
+ groupBy: ['int_field'],
+ });
+
+ assert.containsNone(list, '.o_data_row', "no group should be open");
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ assert.containsNone(list, '.o_data_row',
+ "should wait for async widgets before opening the group");
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_data_row', 1, "group should be open");
+ assert.strictEqual(list.$('.o_data_row .o_data_cell').text(), 'ready',
+ "async widget should be correctly displayed");
+
+ list.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('grouped lists with groups_limit attribute', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree groups_limit="3"><field name="foo"/></tree>',
+ groupBy: ['int_field'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(list, '.o_group_header', 3); // page 1
+ assert.containsNone(list, '.o_data_row');
+ assert.containsOnce(list, '.o_pager'); // has a pager
+
+ await cpHelpers.pagerNext(list); // switch to page 2
+
+ assert.containsN(list, '.o_group_header', 1); // page 2
+ assert.containsNone(list, '.o_data_row');
+
+ assert.verifySteps([
+ 'web_read_group', // read_group page 1
+ 'web_read_group', // read_group page 2
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with expand attribute', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.containsN(list, '.o_group_header', 2);
+ assert.containsN(list, '.o_data_row', 4);
+ assert.strictEqual(list.$('.o_data_cell').text(), 'yopblipgnapblip');
+
+ assert.verifySteps([
+ 'web_read_group', // records are fetched alongside groups
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list (two levels) with expand attribute', async function (assert) {
+ // the expand attribute only opens the first level groups
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1"><field name="foo"/></tree>',
+ groupBy: ['bar', 'int_field'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.containsN(list, '.o_group_header', 6);
+
+ assert.verifySteps([
+ 'web_read_group', // global
+ 'web_read_group', // first group
+ 'web_read_group', // second group
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped lists with expand attribute and a lot of groups', async function (assert) {
+ assert.expect(8);
+
+ for (var i = 0; i < 15; i++) {
+ this.data.foo.records.push({foo: 'record ' + i, int_field: i});
+ }
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1"><field name="foo"/></tree>',
+ groupBy: ['int_field'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(list, '.o_group_header', 10); // page 1
+ assert.containsN(list, '.o_data_row', 11); // one group contains two records
+ assert.containsOnce(list, '.o_pager'); // has a pager
+
+ await cpHelpers.pagerNext(list); // switch to page 2
+
+ assert.containsN(list, '.o_group_header', 7); // page 2
+ assert.containsN(list, '.o_data_row', 7);
+
+ assert.verifySteps([
+ 'web_read_group', // read_group page 1
+ 'web_read_group', // read_group page 2
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('add filter in a grouped list with a pager', async function (assert) {
+ assert.expect(11);
+
+ const actionManager = await createActionManager({
+ data: this.data,
+ actions: [{
+ id: 11,
+ name: 'Action 11',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[3, 'list']],
+ search_view_id: [9, 'search'],
+ flags: {
+ context: { group_by: ['int_field'] },
+ },
+ }],
+ archs: {
+ 'foo,3,list': '<tree groups_limit="3"><field name="foo"/></tree>',
+ 'foo,9,search': `
+ <search>
+ <filter string="Not Bar" name="not bar" domain="[['bar','=',False]]"/>
+ </search>`,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(JSON.stringify(args.kwargs.domain) + ', ' + args.kwargs.offset);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await actionManager.doAction(11);
+
+ assert.containsOnce(actionManager, '.o_list_view');
+ assert.strictEqual(actionManager.$('.o_pager_counter').text().trim(), '1-3 / 4');
+ assert.containsN(actionManager, '.o_group_header', 3); // page 1
+
+ await testUtils.dom.click(actionManager.$('.o_pager_next')); // switch to page 2
+
+ assert.strictEqual(actionManager.$('.o_pager_counter').text().trim(), '4-4 / 4');
+ assert.containsN(actionManager, '.o_group_header', 1); // page 2
+
+ // toggle a filter -> there should be only one group left (on page 1)
+ await cpHelpers.toggleFilterMenu(actionManager);
+ await cpHelpers.toggleMenuItem(actionManager, 0);
+
+ assert.strictEqual(actionManager.$('.o_pager_counter').text().trim(), '1-1 / 1');
+ assert.containsN(actionManager, '.o_group_header', 1); // page 1
+
+ assert.verifySteps([
+ '[], undefined',
+ '[], 3',
+ '[["bar","=",false]], undefined',
+ ]);
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('editable grouped lists', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+
+ // enter edition (grouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ // click on the body should leave the edition
+ await testUtils.dom.click($('body'));
+ assert.containsNone(list, '.o_selected_row');
+
+ // reload without groupBy
+ await list.reload({groupBy: []});
+
+ // enter edition (ungrouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ // click on the body should leave the edition
+ await testUtils.dom.click($('body'));
+ assert.containsNone(list, '.o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped lists are editable (ungrouped first)', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ // enter edition (ungrouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ // reload with groupBy
+ await list.reload({groupBy: ['bar']});
+
+ // open first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ // enter edition (grouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ list.destroy();
+ });
+
+ QUnit.test('char field edition in editable grouped list', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row .o_data_cell:first input[name="foo"]'), 'pla', 'input');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.strictEqual(this.data.foo.records[0].foo, 'pla',
+ "the edition should have been properly saved");
+ assert.containsOnce(list, '.o_data_row:first:contains(pla)');
+
+ list.destroy();
+ });
+
+ QUnit.test('control panel buttons in editable grouped list views', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.isNotVisible(list.$buttons.find('.o_list_button_add'));
+
+ // reload without groupBy
+ await list.reload({groupBy: []});
+ assert.isVisible(list.$buttons.find('.o_list_button_add'));
+
+ list.destroy();
+ });
+
+ QUnit.test('control panel buttons in multi editable grouped list views', async function (assert) {
+ assert.expect(8);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ arch:
+ `<tree multi_edit="1">
+ <field name="foo"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ assert.containsNone(list, '.o_data_row', "all groups should be closed");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 1, "first group should be opened");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ assert.containsOnce(list, '.o_data_row:eq(0) .o_list_record_selector input:enabled',
+ "should have selected first record");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ await testUtils.dom.click(list.$('.o_group_header:last'));
+ assert.containsN(list, '.o_data_row', 2, "two groups should be opened");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ list.destroy();
+ });
+
+ QUnit.test('edit a line and discard it in grouped editable', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="int_field"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:contains(gnap)'));
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third group row should be in edition");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ await testUtils.dom.click(list.$('.o_data_row:nth(0) > td:contains(yop)'));
+ assert.ok(list.$('.o_data_row:eq(0)').is('.o_selected_row'),
+ "first group row should be in edition");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsNone(list, '.o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:contains(gnap)'));
+ assert.containsOnce(list, '.o_selected_row');
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third group row should be in edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('add and discard a record in a multi-level grouped list view', async function (assert) {
+ assert.expect(7);
+
+ testUtils.mock.patch(basicFields.FieldChar, {
+ destroy: function () {
+ assert.step('destroy');
+ this._super.apply(this, arguments);
+ },
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ groupBy: ['foo', 'bar'],
+ });
+
+ // unfold first subgroup
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)'));
+ assert.hasClass(list.$('.o_group_header:first'), 'o_group_open');
+ assert.hasClass(list.$('.o_group_header:eq(1)'), 'o_group_open');
+ assert.containsOnce(list, '.o_data_row');
+
+ // add a record to first subgroup
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));
+ assert.containsN(list, '.o_data_row', 2);
+
+ // discard
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsOnce(list, '.o_data_row');
+
+ assert.verifySteps(['destroy']);
+
+ testUtils.mock.unpatch(basicFields.FieldChar);
+ list.destroy();
+ });
+
+ QUnit.test('inputs are disabled when unselecting rows in grouped editable', async function (assert) {
+ assert.expect(1);
+
+ var $input;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.strictEqual($input.prop('disabled'), true,
+ "input should be disabled");
+ }
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ $input = list.$('tr.o_selected_row input[name="foo"]');
+ await testUtils.fields.editAndTrigger($input, 'lemon', 'input');
+ await testUtils.fields.triggerKeydown($input, 'tab');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ESC in editable grouped list should discard the current line changes', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, 'tr.o_data_row', 3);
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ // update name by "foo"
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row .o_data_cell:first input[name="foo"]'), 'new_value', 'input');
+ // discard by pressing ESC
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'escape');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+
+ assert.containsOnce(list, 'tbody tr td:contains(yop)');
+ assert.containsN(list, 'tr.o_data_row', 3);
+ assert.containsNone(list, 'tr.o_data_row.o_selected_row');
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'));
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable="bottom" grouped list', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to first line of next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go back to first line of first group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable="top" grouped list', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to first line of next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go back to first line of first group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable grouped list with create=0', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" create="0"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to first line of next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go back to first line of first group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable="bottom" grouped list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // navigate inside a group
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // navigate between groups
+ await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable="top" grouped list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // navigate inside a group
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // navigate between groups
+ await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable grouped list with create="0"', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" create="0"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // navigate inside a group
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // navigate between groups
+ await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('editing then pressing TAB in editable grouped list', async function (assert) {
+ assert.expect(19);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // select and edit last row of first group
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ await testUtils.fields.editInput(list.$('.o_selected_row input[name="foo"]'), 'new value');
+
+ // Press 'Tab' -> should create a new record as we edited the previous one
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // fill foo field for the new record and press 'tab' -> should create another record
+ await testUtils.fields.editInput(list.$('.o_selected_row input[name="foo"]'), 'new record');
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+
+ assert.containsN(list, '.o_data_row', 6);
+ assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
+
+ // leave this new row empty and press tab -> should discard the new record and move to the
+ // next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'write',
+ 'read',
+ 'onchange',
+ 'create',
+ 'read',
+ 'onchange',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('editing then pressing TAB (with a readonly field) in grouped list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ fieldDebounce: 1
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'new value', 'input');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.containsOnce(list, 'tbody tr td:contains(new value)');
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ 'write',
+ 'read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ENTER in editable="bottom" grouped list view', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
+ assert.containsN(list, 'tr.o_data_row', 4);
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter in input should move to next record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter on last row should create a new record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.containsN(list, 'tr.o_data_row', 5);
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'onchange',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ENTER in editable="top" grouped list view', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
+ assert.containsN(list, 'tr.o_data_row', 4);
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter in input should move to next record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter on last row should move to first record of next group
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ENTER in editable grouped list view with create=0', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" create="0"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
+ assert.containsN(list, 'tr.o_data_row', 4);
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter in input should move to next record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter on last row should move to first record of next group
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('cell-level keyboard navigation in non-editable list', async function (assert) {
+ assert.expect(16);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" required="1"/></tree>',
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 3,
+ "'switch_view' event has been triggered");
+ },
+ },
+ });
+
+ assert.ok(document.activeElement.classList.contains('o_searchview_input'),
+ 'default focus should be in search view');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be on the record selector');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.ok(document.activeElement.classList.contains('o_searchview_input'),
+ 'focus should have come back to the search view');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be in first row input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus should now be in field TD');
+ assert.strictEqual(document.activeElement.textContent, 'yop',
+ 'focus should now be in first row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.textContent, 'yop',
+ 'should not cycle at end of line');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should now be in second row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'gnap',
+ 'focus should now be in third row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should now be in last row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should still be in last row field (arrows do not cycle)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should still be in last row field (arrows still do not cycle)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be in last row input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'should not cycle at start of line');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.textContent, 'gnap',
+ 'focus should now be in third row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ list.destroy();
+ });
+
+ QUnit.test('removing a groupby while adding a line from list', async function (assert) {
+ assert.expect(1);
+
+ let checkUnselectRow = false;
+ testUtils.mock.patch(ListRenderer, {
+ unselectRow(options = {}) {
+ if (checkUnselectRow) {
+ assert.step('unselectRow');
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree multi_edit="1" editable="bottom">
+ <field name="display_name"/>
+ <field name="foo"/>
+ </tree>
+ `,
+ archs: {
+ 'foo,false,search': `
+ <search>
+ <field name="foo"/>
+ <group expand="1" string="Group By">
+ <filter name="groupby_foo" context="{'group_by': 'foo'}"/>
+ </group>
+ </search>
+ `,
+ },
+ });
+
+ await cpHelpers.toggleGroupByMenu(list);
+ await cpHelpers.toggleMenuItem(list, 0);
+ // expand group
+ await testUtils.dom.click(list.el.querySelector('th.o_group_name'));
+ await testUtils.dom.click(list.el.querySelector('td.o_group_field_row_add a'));
+ checkUnselectRow = true;
+ await testUtils.dom.click($('.o_searchview_facet .o_facet_remove'));
+ assert.verifySteps([]);
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('cell-level keyboard navigation in editable grouped list', async function (assert) {
+ assert.expect(56);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('td:contains(blip)')); // select row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be opened');
+
+ var $secondRowInput = list.$('tr.o_data_row:eq(1) td:eq(1) input');
+ assert.strictEqual($secondRowInput.val(), 'blip',
+ 'second record should be in edit mode');
+
+ await testUtils.fields.editAndTrigger($secondRowInput, 'blipbloup', 'input');
+ assert.strictEqual($secondRowInput.val(), 'blipbloup',
+ 'second record should be changed but not saved yet');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+
+ assert.hasClass($('body'), 'modal-open',
+ 'record has been modified, are you sure modal should be opened');
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be closed');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in field td');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of second record should be focused');
+ assert.strictEqual(list.$('tr.o_data_row:eq(1) td:eq(1)').text(), 'blip',
+ 'change should not have been saved');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'record selector should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in first record td');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ var $firstRowInput = list.$('tr.o_data_row:eq(0) td:eq(1) input');
+ assert.hasClass(list.$('tr.o_data_row:eq(0)'), 'o_selected_row',
+ 'first row should be selected');
+ assert.strictEqual($firstRowInput.val(), 'yop',
+ 'first record should be in edit mode');
+
+ await testUtils.fields.editAndTrigger($firstRowInput, 'Zipadeedoodah', 'input');
+ assert.strictEqual($firstRowInput.val(), 'Zipadeedoodah',
+ 'first record should be changed but not saved yet');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row:eq(0) td:eq(1)').text(), 'Zipadeedoodah',
+ 'first record should be saved');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(0)'), 'o_selected_row',
+ 'first row should be closed');
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be opened');
+ assert.strictEqual(list.$('tr.o_data_row:eq(1) td:eq(1) input').val(), 'blip',
+ 'second record should be in edit mode');
+
+ assert.strictEqual(document.activeElement.value, 'blip',
+ 'second record input should be focused');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.value, 'blip',
+ 'second record input should still be focused (arrows movements are disabled in edit)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.value, 'blip',
+ 'second record input should still be focused (arrows movements are still disabled in edit)');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be closed');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in field td');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of second record should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+
+ assert.strictEqual(document.activeElement.textContent, 'false (1)',
+ 'focus should be on second group header');
+ assert.strictEqual(list.$('tr.o_data_row').length, 3,
+ 'should have 3 rows displayed');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row').length, 4,
+ 'should have 4 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'false (1)',
+ 'focus should still be on second group header');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of last record should be focused');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'arrow navigation should not cycle (focus still on last row)');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ await testUtils.fields.editAndTrigger($('tr.o_data_row:eq(4) td:eq(1) input'),
+ 'cheateur arrete de cheater', 'input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row').length, 6,
+ 'should have 6 rows displayed (new record + new edit line)');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+
+ // come back to the top
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+
+ assert.strictEqual(document.activeElement.tagName, 'TH',
+ 'focus is in table header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus is in header input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in field td');
+ assert.strictEqual(document.activeElement.textContent, 'Zipadeedoodah',
+ 'second field of first record should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus should be on first group header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed (first group should be closed)');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus should still be on first group header');
+
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(list.$('tr.o_data_row').length, 5,
+ 'should have 5 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(list.$('tr.o_data_row').length, 5,
+ 'should have 5 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'false (2)',
+ 'focus should now be on second group header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'record td should be focused');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of first record of second group should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'cheateur arrete de cheater',
+ 'second field of last record of second group should be focused');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.strictEqual(document.activeElement.textContent, 'cheateur arrete de cheater',
+ 'second field of last record of second group should be focused (special case: the first td of the "Add a line" line was skipped');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of first record of second group should be focused');
+
+ list.destroy();
+ });
+
+ QUnit.test('execute group header button with keyboard navigation', async function (assert) {
+ assert.expect(13);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="m2o">' +
+ '<button type="object" name="some_method" string="Do this"/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['m2o'],
+ intercepts: {
+ execute_action: function (ev) {
+ assert.strictEqual(ev.data.action_data.name, 'some_method');
+ },
+ },
+ });
+
+ assert.containsNone(list, '.o_data_row', "all groups should be closed");
+
+ // focus create button as a starting point
+ list.$('.o_list_button_add').focus();
+ assert.ok(document.activeElement.classList.contains('o_list_button_add'));
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be on the record selector (list header)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'Value 1 (3)',
+ 'focus should be on first group header');
+
+ // unfold first group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.containsN(list, '.o_data_row', 3, "first group should be open");
+
+ // move to first record of opened group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should be in first row checkbox');
+
+ // move back to the group header
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.ok(document.activeElement.classList.contains('o_group_name'),
+ 'focus should be back on first group header');
+
+ // fold the group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.ok(document.activeElement.classList.contains('o_group_name'),
+ 'focus should still be on first group header');
+ assert.containsNone(list, '.o_data_row', "first group should now be folded");
+
+ // unfold the group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.ok(document.activeElement.classList.contains('o_group_name'),
+ 'focus should still be on first group header');
+ assert.containsN(list, '.o_data_row', 3, "first group should be open");
+
+ // simulate a move to the group header button with tab (we can't trigger a native event
+ // programmatically, see https://stackoverflow.com/a/32429197)
+ list.$('.o_group_header .o_group_buttons button:first').focus();
+ assert.strictEqual(document.activeElement.tagName, 'BUTTON',
+ 'focus should be on the group header button');
+
+ // click on the button by pressing enter
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.containsN(list, '.o_data_row', 3, "first group should still be open");
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in grouped editable="top" list', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));// add a new row
+ assert.strictEqual(list.$('.o_selected_row .o_input[name=foo]')[0], document.activeElement,
+ 'The first input of the line should have the focus');
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 4);
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard')); // discard new row
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsOnce(list, 'tbody:nth(3) .o_data_row');
+
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ assert.strictEqual(list.$('.o_group_name:eq(1)').text(), 'false (2)',
+ "group should have correct name and count");
+ assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'pla', 'input');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in grouped editable="bottom" list', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));// add a new row
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 4);
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard')); // discard new row
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsOnce(list, 'tbody:nth(3) .o_data_row');
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
+
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'pla', 'input');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('add and discard a line through keyboard navigation without crashing', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ // Triggers ENTER on "Add a line" wrapper cell
+ await testUtils.fields.triggerKeydown(list.$('.o_group_field_row_add'), 'enter');
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 4, "new data row should be created");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ // At this point, a crash manager should appear if no proper link targetting
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 3,"new data row should be discarded.");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable grouped list with create="0"', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" create="0"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ assert.containsNone(list, '.o_group_field_row_add a',
+ "Add a line should not be available in readonly");
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in (selection) grouped editable list', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.fields.priority = {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ };
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="priority"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ groupBy: ['priority'],
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.kwargs.context.default_priority.toString());
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a')); // add a new row
+ await testUtils.dom.click($('body')); // unselect row
+ assert.verifySteps(['1']);
+ assert.strictEqual(list.$('.o_data_row .o_data_cell:eq(1)').text(), 'Low',
+ "should have a column name with a value from the groupby");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ await testUtils.dom.click($('body')); // unselect row
+ assert.strictEqual(list.$('.o_data_row:nth(5) .o_data_cell:eq(1)').text(), 'Medium',
+ "should have a column name with a value from the groupby");
+ assert.verifySteps(['2']);
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in (m2o) grouped editable list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ groupBy: ['m2o'],
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.kwargs.context.default_m2o.toString());
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));
+ await testUtils.dom.click($('body')); // unselect row
+ assert.strictEqual(list.$('tbody:eq(1) .o_data_row:first .o_data_cell:eq(1)').text(), 'Value 1',
+ "should have a column name with a value from the groupby");
+ assert.verifySteps(['1']);
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ await testUtils.dom.click($('body')); // unselect row
+ assert.strictEqual(list.$('tbody:eq(3) .o_data_row:first .o_data_cell:eq(1)').text(), 'Value 2',
+ "should have a column name with a value from the groupby");
+ assert.verifySteps(['2']);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with optional fields rendering', async function (assert) {
+ assert.expect(12);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="amount"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ translateParameters: {
+ direction: 'ltr',
+ }
+ });
+
+ assert.containsN(list, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle',
+ "should have the optional columns dropdown toggle inside the table");
+
+ const optionalFieldsToggler = list.el.querySelector('table').lastElementChild;
+ assert.ok(optionalFieldsToggler.classList.contains('o_optional_columns_dropdown_toggle'),
+ 'The optional fields toggler is the second last element');
+ const optionalFieldsDropdown = list.el.querySelector('.o_list_view').lastElementChild;
+ assert.ok(optionalFieldsDropdown.classList.contains('o_optional_columns'),
+ 'The optional fields dropdown is the last element');
+
+ assert.ok(list.$('.o_optional_columns .dropdown-menu').hasClass('dropdown-menu-right'),
+ 'In LTR, the dropdown should be anchored to the right and expand to the left');
+
+ // optional fields
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.containsN(list, 'div.o_optional_columns div.dropdown-item', 2,
+ "dropdown have 2 optional field foo with checked and bar with unchecked");
+
+ // enable optional field
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ // 5 th (1 for checkbox, 4 for columns)
+ assert.containsN(list, 'th', 4, "should have 4 th");
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ // disable optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.strictEqual(list.$('div.o_optional_columns div.dropdown-item:first input:checked')[0],
+ list.$('div.o_optional_columns div.dropdown-item [name="m2o"]')[0],
+ "m2o advanced field check box should be checked in dropdown");
+
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ // 3 th (1 for checkbox, 2 for columns)
+ assert.containsN(list, 'th', 3, "should have 3 th");
+ assert.notOk(list.$('th:contains(M2O field)').is(':visible'),
+ "should not have a visible m2o field"); //m2o field not displayed
+
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.notOk(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with optional fields rendering in RTL mode', async function (assert) {
+ assert.expect(4);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="amount"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ translateParameters: {
+ direction: 'rtl',
+ }
+ });
+
+ assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle',
+ "should have the optional columns dropdown toggle inside the table");
+
+ const optionalFieldsToggler = list.el.querySelector('table').lastElementChild;
+ assert.ok(optionalFieldsToggler.classList.contains('o_optional_columns_dropdown_toggle'),
+ 'The optional fields toggler is the last element');
+ const optionalFieldsDropdown = list.el.querySelector('.o_list_view').lastElementChild;
+ assert.ok(optionalFieldsDropdown.classList.contains('o_optional_columns'),
+ 'The optional fields is the last element');
+
+ assert.ok(list.$('.o_optional_columns .dropdown-menu').hasClass('dropdown-menu-left'),
+ 'In RTL, the dropdown should be anchored to the left and expand to the right');
+
+ list.destroy();
+ });
+
+ QUnit.test('optional fields do not disappear even after listview reload', async function (assert) {
+ assert.expect(7);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="amount"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.containsN(list, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ // enable optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.notOk(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ assert.containsN(list, 'th', 4,
+ "should have 4 th 1 for selector, 3 for columns");
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ // reload listview
+ await list.reload();
+ assert.containsN(list, 'th', 4,
+ "should have 4 th 1 for selector, 3 for columns ever after listview reload");
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field even after listview reload");
+
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.ok(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+
+ list.destroy();
+ });
+
+ QUnit.test('selection is kept when optional fields are toggled', async function (assert) {
+ assert.expect(7);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.containsN(list, 'th', 2);
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input:first'));
+
+ assert.containsOnce(list, '.o_list_record_selector input:checked');
+
+ // add an optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ assert.containsN(list, 'th', 3);
+
+ assert.containsOnce(list, '.o_list_record_selector input:checked');
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+
+ // remove an optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ assert.containsN(list, 'th', 2);
+
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with optional fields and async rendering', async function (assert) {
+ assert.expect(14);
+
+ const prom = testUtils.makeTestPromise();
+ const FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ async _render() {
+ assert.ok(true, 'the rendering must be async');
+ this._super(...arguments);
+ await prom;
+ },
+ }));
+
+ const RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="m2o"/>
+ <field name="foo" widget="asyncwidget" optional="hide"/>
+ </tree>`,
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.containsN(list, 'th', 2);
+ assert.isNotVisible(list.$('.o_optional_columns_dropdown'));
+
+ // add an optional field (we click on the label on purpose, as it will trigger
+ // a second event on the input)
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.isVisible(list.$('.o_optional_columns_dropdown'));
+ assert.containsNone(list.$('.o_optional_columns_dropdown'), 'input:checked');
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first label'));
+
+ assert.containsN(list, 'th', 2);
+ assert.isVisible(list.$('.o_optional_columns_dropdown'));
+ assert.containsNone(list.$('.o_optional_columns_dropdown'), 'input:checked');
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, 'th', 3);
+ assert.isVisible(list.$('.o_optional_columns_dropdown'));
+ assert.containsOnce(list.$('.o_optional_columns_dropdown'), 'input:checked');
+
+ list.destroy();
+ delete fieldRegistry.map.asyncwidget;
+ });
+
+ QUnit.test('open list optional fields dropdown position to right place', async function (assert) {
+ assert.expect(1);
+
+ this.data.bar.fields.name = { string: "Name", type: "char", sortable: true };
+ this.data.bar.fields.foo = { string: "Foo", type: "char", sortable: true };
+ this.data.foo.records[0].o2m = [1, 2];
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <sheet>
+ <notebook>
+ <page string="Page 1">
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="foo"/>
+ <field name="name" optional="hide"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ res_id: 1,
+ });
+
+ const listWidth = form.el.querySelector('.o_list_view').offsetWidth;
+
+ await testUtils.dom.click(form.el.querySelector('.o_optional_columns_dropdown_toggle'));
+ assert.strictEqual(form.el.querySelector('.o_optional_columns').offsetLeft, listWidth,
+ "optional fields dropdown should opened at right place");
+
+ form.destroy();
+ });
+
+ QUnit.test('change the viewType of the current action', async function (assert) {
+ assert.expect(25);
+
+ this.actions = [{
+ id: 1,
+ name: 'Partners Action 1',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[1, 'kanban']],
+ }, {
+ id: 2,
+ name: 'Partners',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list'], [1, 'kanban']],
+ }];
+
+ this.archs = {
+ 'foo,1,kanban': '<kanban><templates><t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click"><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+
+ 'foo,false,list': '<tree limit="3">' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="o2m" optional="show"/></tree>',
+
+ 'foo,false,search': '<search><field name="foo" string="Foo"/></search>',
+ };
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var actionManager = await testUtils.createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+ await actionManager.doAction(2);
+
+ assert.containsOnce(actionManager, '.o_list_view',
+ "should have rendered a list view");
+
+ assert.containsN(actionManager, 'th', 3, "should display 3 th (selector + 2 fields)");
+
+ // enable optional field
+ await testUtils.dom.click(actionManager.$('table .o_optional_columns_dropdown_toggle'));
+ assert.notOk(actionManager.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+ assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="o2m"]').is(":checked"));
+ await testUtils.dom.click(actionManager.$('div.o_optional_columns div.dropdown-item:first'));
+ assert.containsN(actionManager, 'th', 4, "should display 4 th (selector + 3 fields)");
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ // switch to kanban view
+ await actionManager.loadState({
+ action: 2,
+ view_type: 'kanban',
+ });
+
+ assert.containsNone(actionManager, '.o_list_view',
+ "should not display the list view anymore");
+ assert.containsOnce(actionManager, '.o_kanban_view',
+ "should have switched to the kanban view");
+
+ // switch back to list view
+ await actionManager.loadState({
+ action: 2,
+ view_type: 'list',
+ });
+
+ assert.containsNone(actionManager, '.o_kanban_view',
+ "should not display the kanban view anymoe");
+ assert.containsOnce(actionManager, '.o_list_view',
+ "should display the list view");
+
+ assert.containsN(actionManager, 'th', 4, "should display 4 th");
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+ assert.ok(actionManager.$('th:contains(O2M field)').is(':visible'),
+ "should have a visible o2m field"); //m2o field
+
+ // disable optional field
+ await testUtils.dom.click(actionManager.$('table .o_optional_columns_dropdown_toggle'));
+ assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+ assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="o2m"]').is(":checked"));
+ await testUtils.dom.click(actionManager.$('div.o_optional_columns div.dropdown-item:last input'));
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+ assert.notOk(actionManager.$('th:contains(O2M field)').is(':visible'),
+ "should have a visible o2m field"); //m2o field
+ assert.containsN(actionManager, 'th', 3, "should display 3 th");
+
+ await actionManager.doAction(1);
+
+ assert.containsNone(actionManager, '.o_list_view',
+ "should not display the list view anymore");
+ assert.containsOnce(actionManager, '.o_kanban_view',
+ "should have switched to the kanban view");
+
+ await actionManager.doAction(2);
+
+ assert.containsNone(actionManager, '.o_kanban_view',
+ "should not havethe kanban view anymoe");
+ assert.containsOnce(actionManager, '.o_list_view',
+ "should display the list view");
+
+ assert.containsN(actionManager, 'th', 3, "should display 3 th");
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+ assert.notOk(actionManager.$('th:contains(O2M field)').is(':visible'),
+ "should have a visible o2m field"); //m2o field
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('list view with optional fields rendering and local storage mock', async function (assert) {
+ assert.expect(12);
+
+ var forceLocalStorage = true;
+
+ var Storage = RamStorage.extend({
+ getItem: function (key) {
+ assert.step('getItem ' + key);
+ return forceLocalStorage ? '["m2o"]' : this._super.apply(this, arguments);
+ },
+ setItem: function (key, value) {
+ assert.step('setItem ' + key + ' to ' + value);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new Storage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="reference" optional="show"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ view_id: 42,
+ });
+
+ var localStorageKey = 'optional_fields,foo,list,42,foo,m2o,reference';
+
+ assert.verifySteps(['getItem ' + localStorageKey]);
+
+ assert.containsN(list, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ assert.notOk(list.$('th:contains(Reference Field)').is(':visible'),
+ "should not have a visible reference field");
+
+ // optional fields
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.containsN(list, 'div.o_optional_columns div.dropdown-item', 2,
+ "dropdown have 2 optional fields");
+
+ forceLocalStorage = false;
+ // enable optional field
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:eq(1) input'));
+
+ assert.verifySteps([
+ 'setItem ' + localStorageKey + ' to ["m2o","reference"]',
+ 'getItem ' + localStorageKey,
+ ]);
+
+ // 4 th (1 for checkbox, 3 for columns)
+ assert.containsN(list, 'th', 4, "should have 4 th");
+
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ assert.ok(list.$('th:contains(Reference Field)').is(':visible'),
+ "should have a visible reference field");
+
+ list.destroy();
+ });
+ QUnit.test("quickcreate in a many2one in a list", async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: '<tree editable="top"><field name="m2o"/></tree>',
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+
+ const $input = list.$('.o_data_row:first .o_data_cell:first input');
+ await testUtils.fields.editInput($input, "aaa");
+ $input.trigger('keyup');
+ $input.trigger('blur');
+ document.body.click();
+
+ await testUtils.nextTick();
+
+ assert.containsOnce(document.body, '.modal', "the quick_create modal should appear");
+
+ await testUtils.dom.click($('.modal .btn-primary:first'));
+ await testUtils.dom.click(document.body);
+
+ assert.strictEqual(list.el.getElementsByClassName('o_data_cell')[0].innerHTML, "aaa",
+ "value should have been updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('float field render with digits attribute on listview', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="qux" digits="[12,6]"/></tree>',
+ });
+
+ assert.strictEqual(list.$('td.o_list_number:eq(0)').text(), "0.400000", "should contain 6 digits decimal precision");
+ list.destroy();
+ });
+ // TODO: write test on:
+ // - default_get with a field not in view
+
+ QUnit.test('editable list: resize column headers', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ });
+
+ // Target handle
+ const th = list.el.getElementsByTagName('th')[1];
+ const optionalDropdown = list.el.getElementsByClassName('o_optional_columns')[0];
+ const optionalInitialX = optionalDropdown.getBoundingClientRect().x;
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const originalWidth = th.offsetWidth;
+ const expectedWidth = Math.floor(originalWidth / 2 + resizeHandle.offsetWidth / 2);
+ const delta = originalWidth - expectedWidth;
+
+ await testUtils.dom.dragAndDrop(resizeHandle, th, { mousemoveTarget: window, mouseupTarget: window });
+ const optionalFinalX = Math.floor(optionalDropdown.getBoundingClientRect().x);
+
+ assert.strictEqual(th.offsetWidth, expectedWidth,
+ // 1px for the cell right border
+ "header width should be halved (plus half the width of the handle)");
+ assert.strictEqual(optionalFinalX, optionalInitialX - delta,
+ "optional columns dropdown should have moved the same amount");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: resize column headers with max-width', async function (assert) {
+ // This test will ensure that, on resize list header,
+ // the resized element have the correct size and other elements are not resized
+ assert.expect(2);
+ this.data.foo.records[0].foo = "a".repeat(200);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ });
+
+ // Target handle
+ const th = list.el.getElementsByTagName('th')[1];
+ const thNext = list.el.getElementsByTagName('th')[2];
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const nextResizeHandle = thNext.getElementsByClassName('o_resize')[0];
+ const thOriginalWidth = th.offsetWidth;
+ const thNextOriginalWidth = thNext.offsetWidth;
+ const thExpectedWidth = Math.floor(thOriginalWidth + thNextOriginalWidth);
+
+ await testUtils.dom.dragAndDrop(resizeHandle, nextResizeHandle, { mousemoveTarget: window, mouseupTarget: window });
+
+ const thFinalWidth = th.offsetWidth;
+ const thNextFinalWidth = thNext.offsetWidth;
+ const thWidthDiff = Math.abs(thExpectedWidth - thFinalWidth)
+
+ assert.ok(thWidthDiff <= 1, "Wrong width on resize");
+ assert.ok(thNextOriginalWidth === thNextFinalWidth, "Width must not have been changed");
+
+ list.destroy();
+ });
+
+ QUnit.test('resize column with several x2many lists in form group', async function (assert) {
+ assert.expect(3);
+
+ this.data.bar.fields.text = {string: "Text field", type: "char"};
+ this.data.foo.records[0].o2m = [1, 2];
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="text"/>
+ </tree>
+ </field>
+ <field name="m2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="text"/>
+ </tree>
+ </field>
+ </group>
+ </form>`,
+ res_id: 1,
+ });
+
+ const th = form.el.getElementsByTagName('th')[0];
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const firstTableInitialWidth = form.el.querySelectorAll('.o_field_x2many_list table')[0].offsetWidth;
+ const secondTableInititalWidth = form.el.querySelectorAll('.o_field_x2many_list table')[1].offsetWidth;
+
+ assert.strictEqual(firstTableInitialWidth, secondTableInititalWidth,
+ "both table columns have same width");
+
+ await testUtils.dom.dragAndDrop(resizeHandle, form.el.getElementsByTagName('th')[1], { position: "right" });
+
+ assert.notEqual(firstTableInitialWidth, form.el.querySelectorAll('thead')[0].offsetWidth,
+ "first o2m table is resized and width of table has changed");
+ assert.strictEqual(secondTableInititalWidth, form.el.querySelectorAll('thead')[1].offsetWidth,
+ "second o2m table should not be impacted on first o2m in group resized");
+
+ form.destroy();
+ });
+
+ QUnit.test('resize column with x2many list with several fields in form notebook', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records[0].o2m = [1, 2];
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <sheet>
+ <notebook>
+ <page string="Page 1">
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="display_name"/>
+ <field name="display_name"/>
+ <field name="display_name"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ res_id: 1,
+ });
+
+ const th = form.el.getElementsByTagName('th')[0];
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const listInitialWidth = form.el.querySelector('.o_list_view').offsetWidth;
+
+ await testUtils.dom.dragAndDrop(resizeHandle, form.el.getElementsByTagName('th')[1], { position: "right" });
+
+ assert.strictEqual(form.el.querySelector('.o_list_view').offsetWidth, listInitialWidth,
+ "resizing the column should not impact the width of list");
+
+ form.destroy();
+ });
+
+ QUnit.test('enter edition in editable list with <widget>', async function (assert) {
+ assert.expect(1);
+
+ var MyWidget = Widget.extend({
+ start: function () {
+ this.$el.html('<i class="fa fa-info"/>');
+ },
+ });
+ widgetRegistry.add('some_widget', MyWidget);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<widget name="some_widget"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '</tree>',
+ });
+
+ // click on int_field cell of first row
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:nth(1)'));
+ assert.strictEqual(document.activeElement.name, "int_field");
+
+ list.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('enter edition in editable list with multi_edit = 0', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" multi_edit="0">' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ // click on int_field cell of first row
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:nth(0)'));
+ assert.strictEqual(document.activeElement.name, "int_field");
+
+ list.destroy();
+ });
+
+ QUnit.test('enter edition in editable list with multi_edit = 1', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" multi_edit="1">' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ // click on int_field cell of first row
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:nth(0)'));
+ assert.strictEqual(document.activeElement.name, "int_field");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with field component: mounted and willUnmount calls', async function (assert) {
+ // this test could be removed as soon as the list view will be written in Owl
+ assert.expect(7);
+
+ let mountedCalls = 0;
+ let willUnmountCalls = 0;
+ class MyField extends AbstractFieldOwl {
+ mounted() {
+ mountedCalls++;
+ }
+ willUnmount() {
+ willUnmountCalls++;
+ }
+ }
+ MyField.template = owl.tags.xml`<span>Hello World</span>`;
+ fieldRegistryOwl.add('my_owl_field', MyField);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" widget="my_owl_field"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.strictEqual(mountedCalls, 4);
+ assert.strictEqual(willUnmountCalls, 0);
+
+ await list.reload();
+ assert.strictEqual(mountedCalls, 8);
+ assert.strictEqual(willUnmountCalls, 4);
+
+ list.destroy();
+ assert.strictEqual(mountedCalls, 8);
+ assert.strictEqual(willUnmountCalls, 8);
+ });
+
+ QUnit.test('editable list view: multi edition of owl field component', async function (assert) {
+ // this test could be removed as soon as all field widgets will be written in owl
+ assert.expect(5);
+
+ const list = await createView({
+ arch: '<tree multi_edit="1"><field name="bar"/></tree>',
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsN(list, '.o_data_cell .custom-checkbox input:checked', 3);
+
+ // select all records and edit the boolean field
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsN(list, '.o_data_row .o_list_record_selector input:checked', 4);
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.dom.click(list.$('.o_data_cell .o_field_boolean input'));
+
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+
+ assert.containsNone(list, '.o_data_cell .custom-checkbox input:checked');
+
+ list.destroy();
+ });
+
+ QUnit.test("Date in evaluation context works with date field", async function (assert) {
+ assert.expect(11);
+
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ const unpatchDate = testUtils.mock.patchDate(1997, 0, 9, 12, 0, 0);
+ testUtils.mock.patch(BasicModel, {
+ _getEvalContext() {
+ const evalContext = this._super(...arguments);
+ assert.ok(dateRegex.test(evalContext.today));
+ assert.strictEqual(evalContext.current_date, evalContext.today);
+ return evalContext;
+ },
+ });
+
+ this.data.foo.fields.birthday = { string: "Birthday", type: 'date' };
+ this.data.foo.records[0].birthday = "1997-01-08";
+ this.data.foo.records[1].birthday = "1997-01-09";
+ this.data.foo.records[2].birthday = "1997-01-10";
+
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="birthday" decoration-danger="birthday > today"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.containsOnce(list, ".o_data_row .text-danger");
+
+ list.destroy();
+ unpatchDate();
+ testUtils.mock.unpatch(BasicModel);
+ });
+
+ QUnit.test("Datetime in evaluation context works with datetime field", async function (assert) {
+ assert.expect(6);
+
+ const datetimeRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
+ const unpatchDate = testUtils.mock.patchDate(1997, 0, 9, 12, 0, 0);
+ testUtils.mock.patch(BasicModel, {
+ _getEvalContext() {
+ const evalContext = this._super(...arguments);
+ assert.ok(datetimeRegex.test(evalContext.now));
+ return evalContext;
+ },
+ });
+
+ /**
+ * Returns "1997-01-DD HH:MM:00" with D, H and M holding current UTC values
+ * from patched date + (deltaMinutes) minutes.
+ * This is done to allow testing from any timezone since UTC values are
+ * calculated with the offset of the current browser.
+ */
+ function dateStringDelta(deltaMinutes) {
+ const d = new Date(Date.now() + 1000 * 60 * deltaMinutes);
+ return `1997-01-${
+ String(d.getUTCDate()).padStart(2, '0')
+ } ${
+ String(d.getUTCHours()).padStart(2, '0')
+ }:${
+ String(d.getUTCMinutes()).padStart(2, '0')
+ }:00`;
+ }
+
+ // "datetime" field may collide with "datetime" object in context
+ this.data.foo.fields.birthday = { string: "Birthday", type: 'datetime' };
+ this.data.foo.records[0].birthday = dateStringDelta(-30);
+ this.data.foo.records[1].birthday = dateStringDelta(0);
+ this.data.foo.records[2].birthday = dateStringDelta(+30);
+
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="birthday" decoration-danger="birthday > now"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.containsOnce(list, ".o_data_row .text-danger");
+
+ list.destroy();
+ unpatchDate();
+ testUtils.mock.unpatch(BasicModel);
+ });
+
+ QUnit.test("update control panel while list view is mounting", async function (assert) {
+ const ControlPanel = require('web.ControlPanel');
+ const ListController = require('web.ListController');
+
+ let mountedCounterCall = 0;
+
+ ControlPanel.patch('test.ControlPanel', T => {
+ class ControlPanelPatchTest extends T {
+ mounted() {
+ mountedCounterCall = mountedCounterCall + 1;
+ assert.step(`mountedCounterCall-${mountedCounterCall}`);
+ super.mounted(...arguments);
+ }
+ }
+ return ControlPanelPatchTest;
+ });
+
+ const MyListView = ListView.extend({
+ config: Object.assign({}, ListView.prototype.config, {
+ Controller: ListController.extend({
+ async start() {
+ await this._super(...arguments);
+ this.renderer._updateSelection();
+ },
+ }),
+ }),
+ });
+
+ assert.expect(2);
+
+ const list = await createView({
+ View: MyListView,
+ model: 'event',
+ data: this.data,
+ arch: '<tree><field name="name"/></tree>',
+ });
+
+ assert.verifySteps([
+ 'mountedCounterCall-1',
+ ]);
+
+ ControlPanel.unpatch('test.ControlPanel');
+
+ list.destroy();
+ });
+
+ QUnit.test('edition, then navigation with tab (with a readonly re-evaluated field and onchange)', async function (assert) {
+ // This test makes sure that if we have a cell in a row that will become
+ // read-only after editing another cell, in case the keyboard navigation
+ // move over it before it becomes read-only and there are unsaved changes
+ // (which will trigger an onchange), the focus of the next activable
+ // field will not crash
+ assert.expect(4);
+
+ this.data.bar.onchanges = {
+ o2m: function () {},
+ };
+ this.data.bar.fields.o2m = {string: "O2M field", type: "one2many", relation: "foo"};
+ this.data.bar.records[0].o2m = [1, 4];
+
+ var form = await createView({
+ View: FormView,
+ model: 'bar',
+ res_id: 1,
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="display_name"/>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="date" attrs="{\'readonly\': [(\'foo\', \'!=\', \'yop\')]}"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.method + ':' + args.model);
+ }
+ return this._super.apply(this, arguments);
+ },
+ fieldDebounce: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var jq_evspecial_focus_trigger = $.event.special.focus.trigger;
+ // As KeyboardEvent will be triggered by JS and not from the
+ // User-Agent itself, the focus event will not trigger default
+ // action (event not being trusted), we need to manually trigger
+ // 'change' event on the currently focused element
+ $.event.special.focus.trigger = function () {
+ if (this !== document.activeElement && this.focus) {
+ var activeElement = document.activeElement;
+ this.focus();
+ $(activeElement).trigger('change');
+ }
+ };
+
+ // editable list, click on first td and press TAB
+ await testUtils.dom.click(form.$('.o_data_cell:contains(yop)'));
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="foo"]')[0],
+ "focus should be on an input with name = foo");
+ testUtils.fields.editInput(form.$('tr.o_selected_row input[name="foo"]'), 'new value');
+ var tabEvent = $.Event("keydown", { which: $.ui.keyCode.TAB });
+ await testUtils.dom.triggerEvents(form.$('tr.o_selected_row input[name="foo"]'), [tabEvent]);
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="int_field"]')[0],
+ "focus should be on an input with name = int_field");
+
+ // Restore origin jQuery special trigger for 'focus'
+ $.event.special.focus.trigger = jq_evspecial_focus_trigger;
+
+ assert.verifySteps(["onchange:bar"], "onchange method should have been called");
+ form.destroy();
+ });
+});
+
+});
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
new file mode 100644
index 00000000..9760cfbc
--- /dev/null
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -0,0 +1,3294 @@
+odoo.define('web.pivot_tests', function (require) {
+"use strict";
+
+var core = require('web.core');
+var PivotView = require('web.PivotView');
+const PivotController = require("web.PivotController");
+var testUtils = require('web.test_utils');
+var testUtilsDom = require('web.test_utils_dom');
+
+var _t = core._t;
+const cpHelpers = testUtils.controlPanel;
+var createActionManager = testUtils.createActionManager;
+var createView = testUtils.createView;
+var patchDate = testUtils.mock.patchDate;
+
+/**
+ * Helper function that returns, given a pivot instance, the values of the
+ * table, separated by ','.
+ *
+ * @returns {string}
+ */
+var getCurrentValues = function (pivot) {
+ return pivot.$('.o_pivot_cell_value div').map(function () {
+ return $(this).text();
+ }).get().join();
+};
+
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ foo: {string: "Foo", type: "integer", searchable: true, group_operator: 'sum'},
+ bar: {string: "bar", type: "boolean", store: true, sortable: true},
+ date: {string: "Date", type: "date", store: true, sortable: true},
+ product_id: {string: "Product", type: "many2one", relation: 'product', store: true},
+ other_product_id: {string: "Other Product", type: "many2one", relation: 'product', store: true},
+ non_stored_m2o: {string: "Non Stored M2O", type: "many2one", relation: 'product'},
+ customer: {string: "Customer", type: "many2one", relation: 'customer', store: true},
+ computed_field: {string: "Computed and not stored", type: 'integer', compute: true, group_operator: 'sum'},
+ company_type: {
+ string: "Company Type", type: "selection",
+ selection: [["company", "Company"], ["individual", "individual"]],
+ searchable: true, sortable: true, store: true,
+ },
+ },
+ records: [
+ {
+ id: 1,
+ foo: 12,
+ bar: true,
+ date: '2016-12-14',
+ product_id: 37,
+ customer: 1,
+ computed_field: 19,
+ company_type: 'company',
+ }, {
+ id: 2,
+ foo: 1,
+ bar: true,
+ date: '2016-10-26',
+ product_id: 41,
+ customer: 2,
+ computed_field: 23,
+ company_type: 'individual',
+ }, {
+ id: 3,
+ foo: 17,
+ bar: true,
+ date: '2016-12-15',
+ product_id: 41,
+ customer: 2,
+ computed_field: 26,
+ company_type: 'company',
+ }, {
+ id: 4,
+ foo: 2,
+ bar: false,
+ date: '2016-04-11',
+ product_id: 41,
+ customer: 1,
+ computed_field: 19,
+ company_type: 'individual',
+ },
+ ]
+ },
+ product: {
+ fields: {
+ name: {string: "Product Name", type: "char"}
+ },
+ records: [{
+ id: 37,
+ display_name: "xphone",
+ }, {
+ id: 41,
+ display_name: "xpad",
+ }]
+ },
+ customer: {
+ fields: {
+ name: {string: "Customer Name", type: "char"}
+ },
+ records: [{
+ id: 1,
+ display_name: "First",
+ }, {
+ id: 2,
+ display_name: "Second",
+ }]
+ },
+ };
+ },
+}, function () {
+ QUnit.module('PivotView');
+
+ QUnit.test('simple pivot rendering', async function (assert) {
+ assert.expect(3);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.kwargs.lazy, false,
+ "the read_group should be done with the lazy=false option");
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.hasClass(pivot.$('table'), 'o_enable_linking',
+ "table should have classname 'o_enable_linking'");
+ assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32)').length, 1,
+ "should contain a pivot cell with the sum of all records");
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot rendering with widget', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '<field name="foo" type="measure" widget="float_time"/>' +
+ '</pivot>',
+ });
+
+ assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32:00)').length, 1,
+ "should contain a pivot cell with the sum of all records");
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot rendering with string attribute on field', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.foo = {string: "Foo", type: "integer", store: true, group_operator: 'sum'};
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '<field name="foo" string="BAR" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.strictEqual(pivot.$('.o_pivot_measure_row').text(), "BAR",
+ "the displayed name should be the one set in the string attribute");
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot rendering with string attribute on non stored field', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.fubar = {string: "Fubar", type: "integer", store: false, group_operator: 'sum'};
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '<field name="fubar" string="fubar" type="measure"/>' +
+ '</pivot>',
+ });
+ assert.containsOnce(pivot, '.o_pivot', 'Non stored fields can have a string attribute');
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot rendering with invisible attribute on field', async function (assert) {
+ assert.expect(3);
+ // when invisible, a field should neither be an active measure,
+ // nor be a selectable measure.
+ _.extend(this.data.partner.fields, {
+ foo: {string: "Foo", type: "integer", store: true, group_operator: 'sum'},
+ foo2: {string: "Foo2", type: "integer", store: true, group_operator: 'sum'}
+ });
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '<field name="foo" type="measure"/>' +
+ '<field name="foo2" type="measure" invisible="True"/>' +
+ '</pivot>',
+ });
+
+ // there should be only one displayed measure as the other one is invisible
+ assert.containsOnce(pivot, '.o_pivot_measure_row');
+ // there should be only one measure besides count, as the other one is invisible
+ assert.containsN(pivot, '.o_cp_bottom_left .dropdown-item', 2);
+ // the invisible field souldn't be in the groupable fields neither
+ await testUtils.dom.click(pivot.$('.o_pivot_header_cell_closed:first'));
+ assert.containsNone(pivot, '.o_pivot_field_menu a[data-field="foo2"]');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view without "string" attribute', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ // this is important for export functionality.
+ assert.strictEqual(pivot.title, _t("Untitled"), "should have a valid title");
+ pivot.destroy();
+ });
+
+ QUnit.test('group headers should have a tooltip', async function (assert) {
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="col"/>' +
+ '<field name="date" type="row"/>' +
+ '</pivot>',
+ });
+
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:first').attr('data-original-title'), 'Date');
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:first').attr('data-original-title'), 'Product');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view add computed fields explicitly defined as measure', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="computed_field" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.ok(pivot.measures.computed_field, "measures contains the field 'computed_field'");
+ pivot.destroy();
+ });
+
+ QUnit.test('clicking on a cell triggers a do_action', async function (assert) {
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ intercepts: {
+ do_action: function (ev) {
+ assert.deepEqual(ev.data.action, {
+ context: {someKey: true, userContextKey: true},
+ domain: [['product_id', '=', 37]],
+ name: 'Partners',
+ res_model: 'partner',
+ target: 'current',
+ type: 'ir.actions.act_window',
+ view_mode: 'list',
+ views: [[false, 'list'], [2, 'form']],
+ }, "should trigger do_action with the correct args");
+ },
+ },
+ session: {
+ user_context: {userContextKey: true},
+ },
+ viewOptions: {
+ action: {
+ views: [
+ { viewID: 2, type: 'form' },
+ { viewID: 5, type: 'kanban' },
+ { viewID: false, type: 'list' },
+ { viewID: false, type: 'pivot' },
+ ],
+ },
+ context: {someKey: true, search_default_test: 3},
+ title: 'Partners',
+ }
+ });
+
+ assert.hasClass(pivot.$('table'), 'o_enable_linking',
+ "table should have classname 'o_enable_linking'");
+ await testUtils.dom.click(pivot.$('.o_pivot_cell_value:contains(12)')); // should trigger a do_action
+
+ pivot.destroy();
+ });
+
+ QUnit.test('row and column are highlighted when hovering a cell', async function (assert) {
+ assert.expect(11);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '<field name="foo" type="col"/>' +
+ '<field name="product_id" type="row"/>' +
+ '</pivot>',
+ });
+
+ // check row highlighting
+ assert.hasClass(pivot.$('table'), 'table-hover',
+ "with className 'table-hover', rows are highlighted (bootstrap)");
+
+ // check column highlighting
+ // hover third measure
+ await testUtils.dom.triggerEvents(pivot.$('th.o_pivot_measure_row:nth(2)'), 'mouseover');
+ assert.containsN(pivot, '.o_cell_hover', 3);
+ for (var i = 0; i < 3; i++) {
+ assert.hasClass(pivot.$('tbody tr:nth(' + i + ') td:nth(2)'), 'o_cell_hover');
+ }
+ await testUtils.dom.triggerEvents(pivot.$('th.o_pivot_measure_row:nth(2)'), 'mouseout');
+ assert.containsNone(pivot, '.o_cell_hover');
+
+ // hover second cell, second row
+ await testUtils.dom.triggerEvents(pivot.$('tbody tr:nth(1) td:nth(1)'), 'mouseover');
+ assert.containsN(pivot, '.o_cell_hover', 3);
+ for (i = 0; i < 3; i++) {
+ assert.hasClass(pivot.$('tbody tr:nth(' + i + ') td:nth(1)'), 'o_cell_hover');
+ }
+ await testUtils.dom.triggerEvents(pivot.$('tbody tr:nth(1) td:nth(1)'), 'mouseout');
+ assert.containsNone(pivot, '.o_cell_hover');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view with disable_linking="True"', async function (assert) {
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot disable_linking="True">' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ intercepts: {
+ do_action: function () {
+ assert.ok(false, "should not trigger do_action");
+ },
+ },
+ });
+
+ assert.doesNotHaveClass(pivot.$('table'), 'o_enable_linking',
+ "table should not have classname 'o_enable_linking'");
+ assert.containsOnce(pivot, '.o_pivot_cell_value',
+ "should have one cell");
+ await testUtils.dom.click(pivot.$('.o_pivot_cell_value')); // should not trigger a do_action
+
+ pivot.destroy();
+ });
+
+ QUnit.test('clicking on the "Total" Cell with time range activated gives the right action domain', async function (assert) {
+ assert.expect(2);
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot/>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='this_month'/>
+ </search>
+ `,
+ },
+ intercepts: {
+ do_action: function (ev) {
+ assert.deepEqual(
+ ev.data.action.domain,
+ ["&",["date",">=","2016-12-01"],["date","<=","2016-12-31"]],
+ "should trigger do_action with the correct action domain"
+ );
+ },
+ },
+ viewOptions: {
+ context: { search_default_date_filter: true, },
+ title: 'Partners',
+ },
+ });
+
+ assert.hasClass(pivot.$('table'), 'o_enable_linking',
+ "root node should have classname 'o_enable_linking'");
+ await testUtilsDom.click(pivot.$('.o_pivot_cell_value'));
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('clicking on a fake cell value ("empty group") in comparison mode gives an action domain equivalent to [[0,"=",1]]', async function (assert) {
+ assert.expect(3);
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+
+ this.data.partner.records[0].date = '2016-11-15';
+ this.data.partner.records[1].date = '2016-11-17';
+ this.data.partner.records[2].date = '2016-11-22';
+ this.data.partner.records[3].date = '2016-11-03';
+
+ var first_do_action = true;
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '</pivot>',
+ intercepts: {
+ do_action: function (ev) {
+ if (first_do_action) {
+ assert.deepEqual(
+ ev.data.action.domain,
+ ["&",["date",">=","2016-12-01"],["date","<=","2016-12-31"]],
+ "should trigger do_action with the correct action domain"
+ );
+ } else {
+ assert.deepEqual(
+ ev.data.action.domain,
+ [[0, "=", 1]],
+ "should trigger do_action with the correct action domain"
+ );
+ }
+ first_do_action = false;
+ },
+ },
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='this_month'/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ context: { search_default_date_filter: true, },
+ title: 'Partners',
+ },
+ });
+
+ await cpHelpers.toggleComparisonMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period');
+
+ assert.hasClass(pivot.$('table'), 'o_enable_linking',
+ "root node should have classname 'o_enable_linking'");
+ // here we click on the group corresponding to Total/Total/This Month
+ pivot.$('.o_pivot_cell_value').eq(1).click(); // should trigger a do_action with appropriate domain
+ // here we click on the group corresponding to xphone/Total/This Month
+ pivot.$('.o_pivot_cell_value').eq(4).click(); // should trigger a do_action with appropriate domain
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view grouped by date field', async function (assert) {
+ assert.expect(2);
+
+ var data = this.data;
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ mockRPC: function (route, params) {
+ var wrong_fields = _.filter(params.kwargs.fields, function (field) {
+ return !(field.split(':')[0] in data.partner.fields);
+ });
+ assert.ok(!wrong_fields.length, 'fields given to read_group should exist on the model');
+ return this._super.apply(this, arguments);
+ },
+ });
+ pivot.destroy();
+ });
+
+ QUnit.test('without measures, pivot view uses __count by default', async function (assert) {
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot></pivot>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ assert.deepEqual(args.kwargs.fields, ['__count'],
+ "should make a read_group with no valid fields");
+ }
+ return this._super(route, args);
+ }
+ });
+
+ var $countMeasure = pivot.$buttons.find('.dropdown-item[data-field=__count]:first');
+ assert.hasClass($countMeasure, 'selected', "The count measure should be activated");
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view can be reloaded', async function (assert) {
+ assert.expect(4);
+ var readGroupCount = 0;
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot></pivot>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ readGroupCount++;
+ }
+ return this._super(route, args);
+ }
+ });
+
+ assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(4)').length, 1,
+ "should contain a pivot cell with the number of all records");
+ assert.strictEqual(readGroupCount, 1, "should have done 1 rpc");
+
+ await testUtils.pivot.reload(pivot, {domain: [['foo', '>', 10]]});
+ assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(2)').length, 1,
+ "should contain a pivot cell with the number of remaining records");
+ assert.strictEqual(readGroupCount, 2, "should have done 2 rpcs");
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view grouped by many2one field', async function (assert) {
+ assert.expect(3);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.containsOnce(pivot, '.o_pivot_header_cell_opened',
+ "should have one opened header");
+ assert.strictEqual(pivot.$('.o_pivot_header_cell_closed:contains(xphone)').length, 1,
+ "should display one header with 'xphone'");
+ assert.strictEqual(pivot.$('.o_pivot_header_cell_closed:contains(xpad)').length, 1,
+ "should display one header with 'xpad'");
+ pivot.destroy();
+ });
+
+ QUnit.test('basic folding/unfolding', async function (assert) {
+ assert.expect(7);
+
+ var rpcCount = 0;
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.containsN(pivot, 'tbody tr', 3,
+ "should have 3 rows: 1 for the opened header, and 2 for data");
+
+ // click on the opened header to close it
+ await testUtils.dom.click(pivot.$('.o_pivot_header_cell_opened'));
+
+ assert.containsOnce(pivot, 'tbody tr', "should have 1 row");
+
+ // click on closed header to open dropdown
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
+ assert.containsN(pivot, '.o_pivot_field_menu .dropdown-item[data-field="date"]', 6,
+ "should have the date field as proposition (Date, Day, Week, Month, Quarter and Year)");
+ assert.containsOnce(pivot, '.o_pivot_field_menu .dropdown-item[data-field="product_id"]',
+ "should have the product_id field as proposition");
+ assert.containsNone(pivot, '.o_pivot_field_menu .dropdown-item[data-field="non_stored_m2o"]',
+ "should not have the non_stored_m2o field as proposition");
+
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:first'));
+
+ assert.containsN(pivot, 'tbody tr', 4,
+ "should have 4 rows: one for header, 3 for data");
+ assert.strictEqual(rpcCount, 3,
+ "should have done 3 rpcs (initial load) + open header with different groupbys");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('more folding/unfolding', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ // open dropdown to zoom into first row
+ await testUtils.dom.clickFirst(pivot.$('tbody .o_pivot_header_cell_closed'));
+ // click on date by day
+ pivot.$('.dropdown-menu.show .o_inline_dropdown .dropdown-menu').toggle(); // unfold inline dropdown
+ await testUtils.nextTick();
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:contains("Day")'));
+
+ // open dropdown to zoom into second row
+ await testUtils.dom.clickLast(pivot.$('tbody th.o_pivot_header_cell_closed'));
+
+ assert.containsN(pivot, 'tbody tr', 7,
+ "should have 7 rows (1 for total, 1 for xphone, 1 for xpad, 4 for data)");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('fold and unfold header group', async function (assert) {
+ assert.expect(3);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.containsN(pivot, 'thead tr', 3);
+
+ // fold opened col group
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_opened'));
+ assert.containsN(pivot, 'thead tr', 2);
+
+ // unfold it
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]'));
+ assert.containsN(pivot, 'thead tr', 3);
+
+ pivot.destroy();
+ });
+
+ QUnit.test('unfold second header group', async function (assert) {
+ assert.expect(4);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.containsN(pivot, 'thead tr', 3);
+ var values = ['12', '20', '32'];
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+
+ // unfold it
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="company_type"]'));
+ assert.containsN(pivot, 'thead tr', 4);
+ values = ['12', '3', '17', '32'];
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+
+ pivot.destroy();
+ });
+
+ QUnit.test('can toggle extra measure', async function (assert) {
+ assert.expect(8);
+
+ var rpcCount = 0;
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(pivot, '.o_pivot_cell_value', 3,
+ "should have 3 cells: 1 for the open header, and 2 for data");
+ assert.doesNotHaveClass(pivot.$buttons.find('.dropdown-item[data-field=__count]:first'), 'selected',
+ "the __count measure should not be selected");
+
+ rpcCount = 0;
+ await testUtils.pivot.toggleMeasuresDropdown(pivot);
+ await testUtils.pivot.clickMeasure(pivot, '__count');
+
+ assert.hasClass(pivot.$buttons.find('.dropdown-item[data-field=__count]:first'), 'selected',
+ "the __count measure should be selected");
+ assert.containsN(pivot, '.o_pivot_cell_value', 6,
+ "should have 6 cells: 2 for the open header, and 4 for data");
+ assert.strictEqual(rpcCount, 2,
+ "should have done 2 rpcs to reload data");
+
+ await testUtils.pivot.clickMeasure(pivot, '__count');
+
+ assert.doesNotHaveClass(pivot.$buttons.find('.dropdown-item[data-field=__count]:first'), 'selected',
+ "the __count measure should not be selected");
+ assert.containsN(pivot, '.o_pivot_cell_value', 3,
+ "should have 3 cells: 1 for the open header, and 2 for data");
+ assert.strictEqual(rpcCount, 2,
+ "should not have done any extra rpcs");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('no content helper when no active measure', async function (assert) {
+ assert.expect(4);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '</pivot>',
+ });
+
+ assert.containsNone(pivot, '.o_view_nocontent',
+ "should not have a no_content_helper");
+ assert.containsOnce(pivot, 'table',
+ "should have a table in DOM");
+
+ await testUtils.pivot.toggleMeasuresDropdown(pivot);
+ await testUtils.pivot.clickMeasure(pivot, '__count');
+
+ assert.containsOnce(pivot, '.o_view_nocontent',
+ "should have a no_content_helper");
+ assert.containsNone(pivot, 'table',
+ "should not have a table in DOM");
+ pivot.destroy();
+ });
+
+ QUnit.test('no content helper when no data', async function (assert) {
+ assert.expect(4);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners">' +
+ '</pivot>',
+ });
+
+ assert.containsNone(pivot, '.o_view_nocontent',
+ "should not have a no_content_helper");
+ assert.containsOnce(pivot, 'table',
+ "should have a table in DOM");
+
+ await testUtils.pivot.reload(pivot, {domain: [['foo', '=', 12345]]});
+
+ assert.containsOnce(pivot, '.o_view_nocontent',
+ "should have a no_content_helper");
+ assert.containsNone(pivot, 'table',
+ "should not have a table in DOM");
+ pivot.destroy();
+ });
+
+ QUnit.test('no content helper when no data, part 2', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records = [];
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners"></pivot>',
+ });
+
+ assert.containsOnce(pivot, '.o_view_nocontent',
+ "should have a no_content_helper");
+ pivot.destroy();
+ });
+
+ QUnit.test('no content helper when no data, part 3', async function (assert) {
+ assert.expect(4);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot string="Partners"></pivot>',
+ viewOptions: {
+ domain: [['foo', '=', 12345]]
+ },
+ });
+
+ assert.containsOnce(pivot, '.o_view_nocontent',
+ "should have a no_content_helper");
+ await testUtils.pivot.reload(pivot, {domain: [['foo', '=', 12345]]});
+ assert.containsOnce(pivot, '.o_view_nocontent',
+ "should still have a no_content_helper");
+ await testUtils.pivot.reload(pivot, {domain: []});
+ assert.containsNone(pivot, '.o_view_nocontent',
+ "should not have a no_content_helper");
+
+ // tries to open a field selection menu, to make sure it was not
+ // removed from the dom.
+ await testUtils.dom.clickFirst(pivot.$('.o_pivot_header_cell_closed'));
+ assert.containsOnce(pivot, '.o_pivot_field_menu',
+ "the field selector menu exists");
+ pivot.destroy();
+ });
+
+ QUnit.test('tries to restore previous state after domain change', async function (assert) {
+ assert.expect(5);
+
+ var rpcCount = 0;
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(pivot, '.o_pivot_cell_value', 3,
+ "should have 3 cells: 1 for the open header, and 2 for data");
+ assert.strictEqual(pivot.$('.o_pivot_measure_row:contains(Foo)').length, 1,
+ "should have 1 row for measure Foo");
+
+ await testUtils.pivot.reload(pivot, {domain: [['foo', '=', 12345]]});
+
+ rpcCount = 0;
+ await testUtils.pivot.reload(pivot, {domain: []});
+
+ assert.equal(rpcCount, 2, "should have reloaded data");
+ assert.containsN(pivot, '.o_pivot_cell_value', 3,
+ "should still have 3 cells: 1 for the open header, and 2 for data");
+ assert.strictEqual(pivot.$('.o_pivot_measure_row:contains(Foo)').length, 1,
+ "should still have 1 row for measure Foo");
+ pivot.destroy();
+ });
+
+ QUnit.test('can be grouped with the update function', async function (assert) {
+ assert.expect(4);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.containsOnce(pivot, '.o_pivot_cell_value',
+ "should have only 1 cell");
+ assert.containsOnce(pivot, 'tbody tr',
+ "should have 1 rows");
+
+ await testUtils.pivot.reload(pivot, {groupBy: ['product_id']});
+
+ assert.containsN(pivot, '.o_pivot_cell_value', 3,
+ "should have 3 cells");
+ assert.containsN(pivot, 'tbody tr', 3,
+ "should have 3 rows");
+ pivot.destroy();
+ });
+
+ QUnit.test('can sort data in a column by clicking on header', async function (assert) {
+ assert.expect(3);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '<field name="product_id" type="row"/>' +
+ '</pivot>',
+ });
+
+ assert.strictEqual($('td.o_pivot_cell_value').text(), "321220",
+ "should have proper values in cells (total, result 1, result 2");
+
+ await testUtils.dom.click(pivot.$('th.o_pivot_measure_row'));
+
+ assert.strictEqual($('td.o_pivot_cell_value').text(), "321220",
+ "should have proper values in cells (total, result 1, result 2");
+
+ await testUtils.dom.click(pivot.$('th.o_pivot_measure_row'));
+
+ assert.strictEqual($('td.o_pivot_cell_value').text(), "322012",
+ "should have proper values in cells (total, result 2, result 1");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('can expand all rows', async function (assert) {
+ assert.expect(7);
+
+ var nbReadGroups = 0;
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '<field name="product_id" type="row"/>' +
+ '</pivot>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ nbReadGroups++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(nbReadGroups, 2, "should have done 2 read_group RPCS");
+ assert.strictEqual(pivot.$('td.o_pivot_cell_value').text(), "321220",
+ "should have proper values in cells (total, result 1, result 2)");
+
+ // expand on date:days, product
+ nbReadGroups = 0;
+ await testUtils.pivot.reload(pivot, {groupBy: ['date:days', 'product_id']});
+
+ assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS");
+ assert.containsN(pivot, 'tbody tr', 8,
+ "should have 7 rows (total + 3 for December and 2 for October and April)");
+
+ // collapse the last two rows
+ await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened'));
+ await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened'));
+
+ assert.containsN(pivot, 'tbody tr', 6,
+ "should have 6 rows now");
+
+ // expand all
+ nbReadGroups = 0;
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_expand_button'));
+
+ assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS");
+ assert.containsN(pivot, 'tbody tr', 8,
+ "should have 8 rows again");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('expand all with a delay', async function (assert) {
+ assert.expect(3);
+
+ var def;
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '<field name="product_id" type="row"/>' +
+ '</pivot>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'read_group') {
+ return Promise.resolve(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // expand on date:days, product
+ await testUtils.pivot.reload(pivot, {groupBy: ['date:days', 'product_id']});
+ assert.containsN(pivot, 'tbody tr', 8,
+ "should have 7 rows (total + 3 for December and 2 for October and April)");
+
+ // collapse the last two rows
+ await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened'));
+ await testUtils.dom.clickLast(pivot.$('.o_pivot_header_cell_opened'));
+
+ assert.containsN(pivot, 'tbody tr', 6,
+ "should have 6 rows now");
+
+ // expand all
+ def = testUtils.makeTestPromise();
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_expand_button'));
+ await testUtils.nextTick();
+ def.resolve();
+ // await testUtils.returnAfterNextAnimationFrame();
+ await testUtils.nextTick();
+ assert.containsN(pivot, 'tbody tr', 8,
+ "should have 8 rows again");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('can download a file', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ session: {
+ get_file: function (args) {
+ assert.strictEqual(args.url, '/web/pivot/export_xlsx',
+ "should call get_file with correct parameters");
+ args.complete();
+ },
+ },
+ });
+
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_download'));
+ pivot.destroy();
+ });
+
+ QUnit.test('download a file with single measure, measure row displayed in table', async function (assert) {
+ assert.expect(1);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ session: {
+ get_file: function (args) {
+ const data = JSON.parse(args.data.data);
+ assert.strictEqual(data.measure_headers.length, 4,
+ "should have measure_headers in data");
+ args.complete();
+ },
+ },
+ });
+
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_download'));
+ pivot.destroy();
+ });
+
+ QUnit.test('download button is disabled when there is no data', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records = [];
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.hasAttrValue(pivot.$buttons.find('.o_pivot_download'), 'disabled', 'disabled',
+ "download button should be disabled");
+ pivot.destroy();
+ });
+
+ QUnit.test('getOwnedQueryParams correctly returns measures and groupbys', async function (assert) {
+ assert.expect(3);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="day" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['date:day'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ },
+ }, "context should be correct");
+
+ // expand header on field customer
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]:first'));
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['date:day', 'customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ },
+ }, "context should be correct");
+
+ // expand row on field product_id
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first'));
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['date:day', 'customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ },
+ }, "context should be correct");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly remove pivot_ keys from the context', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'};
+
+ // Equivalent to loading with default filter
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="day" type="col"/>' +
+ '<field name="amount" type="measure"/>' +
+ '</pivot>',
+ viewOptions: {
+ context: {
+ pivot_measures: ['foo'],
+ pivot_column_groupby: ['customer'],
+ pivot_row_groupby: ['product_id'],
+ },
+ },
+ });
+
+ // Equivalent to unload the filter
+ var reloadParams = {
+ context: {},
+ };
+ await pivot.reload(reloadParams);
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ },
+ }, "context should be correct");
+
+ // Let's get rid of the rows groupBy
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_opened'));
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ },
+ }, "context should be correct");
+
+ // And now, get rid of the col groupby
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_opened'));
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: [],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ },
+ }, "context should be correct");
+
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first'));
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: [],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ },
+ }, "context should be correct");
+
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]:first'));
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ },
+ }, "context should be correct");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('Unload Filter, reset display, load another filter', async function (assert) {
+ assert.expect(18);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ viewOptions: {
+ context: {
+ pivot_measures: ['foo'],
+ pivot_column_groupby: ['customer'],
+ pivot_row_groupby: ['product_id'],
+ },
+ },
+ });
+
+ // Check Columns
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_opened').length, 1,
+ 'The column should be grouped');
+ assert.strictEqual(pivot.$('thead tr:contains("First")').length, 1,
+ 'There should be a column "First"');
+ assert.strictEqual(pivot.$('thead tr:contains("Second")').length, 1,
+ 'There should be a column "Second"');
+
+ // Check Rows
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_opened').length, 1,
+ 'The row should be grouped');
+ assert.strictEqual(pivot.$('tbody tr:contains("xphone")').length, 1,
+ 'There should be a row "xphone"');
+ assert.strictEqual(pivot.$('tbody tr:contains("xpad")').length, 1,
+ 'There should be a row "xpad"');
+
+ // Equivalent to unload the filter
+ var reloadParams = {
+ context: {},
+ };
+ await pivot.reload(reloadParams);
+ // collapse all headers
+ await testUtils.dom.click(pivot.$('.o_pivot_header_cell_opened:first'));
+ await testUtils.dom.click(pivot.$('.o_pivot_header_cell_opened'));
+
+ // Check Columns
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed').length, 1,
+ 'The column should not be grouped');
+ assert.strictEqual(pivot.$('thead tr:contains("First")').length, 0,
+ 'There should not be a column "First"');
+ assert.strictEqual(pivot.$('thead tr:contains("Second")').length, 0,
+ 'There should not be a column "Second"');
+
+ // Check Rows
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed').length, 1,
+ 'The row should not be grouped');
+ assert.strictEqual(pivot.$('tbody tr:contains("xphone")').length, 0,
+ 'There should not be a row "xphone"');
+ assert.strictEqual(pivot.$('tbody tr:contains("xpad")').length, 0,
+ 'There should not be a row "xpad"');
+
+ // Equivalent to load another filter
+ reloadParams = {
+ context: {
+ pivot_measures: ['foo'],
+ pivot_column_groupby: ['customer'],
+ pivot_row_groupby: ['product_id'],
+ },
+ };
+ await pivot.reload(reloadParams);
+
+ // Check Columns
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_opened').length, 1,
+ 'The column should be grouped');
+ assert.strictEqual(pivot.$('thead tr:contains("First")').length, 1,
+ 'There should be a column "First"');
+ assert.strictEqual(pivot.$('thead tr:contains("Second")').length, 1,
+ 'There should be a column "Second"');
+
+ // Check Rows
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_opened').length, 1,
+ 'The row should be grouped');
+ assert.strictEqual(pivot.$('tbody tr:contains("xphone")').length, 1,
+ 'There should be a row "xphone"');
+ assert.strictEqual(pivot.$('tbody tr:contains("xpad")').length, 1,
+ 'There should be a row "xpad"');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('Reload, group by columns, reload', async function (assert) {
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot/>',
+ });
+
+ // Set a column groupby
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]'));
+
+ // Set a domain
+ await pivot.update({domain: [['product_id', '=', 37]], groupBy: [], context: {}});
+
+ var expectedContext = {pivot_column_groupby: ['customer'],
+ pivot_measures: ['__count'],
+ pivot_row_groupby: []};
+
+ // Check that column groupbys were not lost
+ assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext},
+ 'Column groupby not lost after first reload');
+
+ // Set a column groupby
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]'));
+
+ // Set a domain
+ await pivot.update({domain: [['product_id', '=', 41]], groupBy: [], context: {}});
+
+ expectedContext = {pivot_column_groupby: ['customer', 'product_id'],
+ pivot_measures: ['__count'],
+ pivot_row_groupby: []};
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext},
+ 'Column groupby not lost after second reload');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('folded groups remain folded at reload', async function (assert) {
+ assert.expect(5);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="company_type" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ var values = [
+ "29", "3", "32",
+ "12", "12",
+ "17", "3", "20",
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+
+ // expand a col group
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]'));
+
+ values = [
+ "29", "1", "2", "32",
+ "12", "12",
+ "17", "1", "2", "20",
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+
+ // expand a row group
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed:nth(1)'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="other_product_id"]'));
+
+ values = [
+ "29", "1", "2", "32",
+ "12", "12",
+ "17", "1", "2", "20",
+ "17", "1", "2", "20",
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+
+ // reload (should keep folded groups folded as col/row groupbys didn't change)
+ await testUtils.pivot.reload(pivot, {context: {}, domain: [], groupBy: []});
+
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+
+ await testUtils.dom.click(pivot.$('.o_pivot_expand_button'));
+
+ // sanity check of what the table should look like if all groups are
+ // expanded, to ensure that the former asserts are pertinent
+ values = [
+ "12", "17", "1", "2", "32",
+ "12", "12",
+ "12", "12",
+ "17", "1", "2", "20",
+ "17", "1", "2", "20",
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+
+ pivot.destroy();
+ });
+
+ QUnit.test('Empty results keep groupbys', async function (assert) {
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot/>',
+ });
+
+ // Set a column groupby
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]'));
+
+ // Set a domain for empty results
+ await pivot.update({domain: [['id', '=', false]]});
+
+ var expectedContext = {pivot_column_groupby: ['customer'],
+ pivot_measures: ['__count'],
+ pivot_row_groupby: []};
+ assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext},
+ 'Column groupby not lost after empty results');
+
+ // Set a domain for not empty results
+ await pivot.update({domain: [['product_id', '=', 37]]});
+
+ assert.deepEqual(pivot.getOwnedQueryParams(), {context: expectedContext},
+ 'Column groupby not lost after reload after empty results');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly uses pivot_ keys from the context', async function (assert) {
+ assert.expect(7);
+
+ this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'};
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="day" type="col"/>' +
+ '<field name="amount" type="measure"/>' +
+ '</pivot>',
+ viewOptions: {
+ context: {
+ pivot_measures: ['foo'],
+ pivot_column_groupby: ['customer'],
+ pivot_row_groupby: ['product_id'],
+ },
+ },
+ });
+
+ assert.containsOnce(pivot, 'thead .o_pivot_header_cell_opened',
+ "column: should have one opened header");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1,
+ "column: should display one closed header with 'First'");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1,
+ "column: should display one closed header with 'Second'");
+
+ assert.containsOnce(pivot, 'tbody .o_pivot_header_cell_opened',
+ "row: should have one opened header");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1,
+ "row: should display one closed header with 'xphone'");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1,
+ "row: should display one closed header with 'xpad'");
+
+ assert.strictEqual(pivot.$('tbody tr:first td:nth(2)').text(), '32',
+ "selected measure should be foo, with total 32");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('clear table cells data after closeGroup', async function (assert) {
+ assert.expect(2);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot/>',
+ groupBy: ['product_id'],
+ });
+
+ await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.el.querySelectorAll('.o_pivot_field_menu .dropdown-item[data-field="date"]')[0]);
+
+ // close and reopen row groupings after changing value
+ this.data.partner.records.find(r => r.product_id === 37).date = '2016-10-27';
+ await testUtils.dom.click(pivot.el.querySelector('tbody .o_pivot_header_cell_opened'));
+ await testUtils.dom.click(pivot.el.querySelector('tbody .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.el.querySelector('.o_pivot_field_menu .dropdown-item[data-field="product_id"]'));
+ assert.strictEqual(pivot.el.querySelectorAll('.o_pivot_cell_value')[4].innerText, ''); // xphone December 2016
+
+ // invert axis, and reopen column groupings
+ await testUtils.dom.click(pivot.el.querySelector('.o_cp_buttons .o_pivot_flip_button'));
+ await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_opened'));
+ await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.el.querySelector('.o_pivot_field_menu .dropdown-item[data-field="product_id"]'));
+ assert.strictEqual(pivot.el.querySelectorAll('.o_pivot_cell_value')[3].innerText, ''); // December 2016 xphone
+
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly group data after flip (1)', async function (assert) {
+ assert.expect(4);
+ const actionManager = await createActionManager({
+ data: this.data,
+ archs: {
+ 'partner,false,pivot': "<pivot/>",
+ 'partner,false,search': `<search><filter name="bayou" string="Bayou" domain="[(1,'=',1)]"/></search>`,
+ 'partner,false,list': '<tree><field name="foo"/></tree>',
+ 'partner,false,form': '<form><field name="foo"/></form>',
+ },
+ });
+
+ await actionManager.doAction({
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'pivot']],
+ context: { group_by: ["product_id"] },
+ });
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // flip axis
+ await testUtils.dom.click(actionManager.el.querySelector('.o_cp_buttons .o_pivot_flip_button'));
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ ]
+ );
+
+ // select filter "Bayou" in control panel
+ await cpHelpers.toggleFilterMenu(actionManager);
+ await cpHelpers.toggleMenuItem(actionManager, "Bayou");
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // close row header "Total"
+ await testUtils.dom.click(actionManager.el.querySelector('tbody .o_pivot_header_cell_opened'));
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total"
+ ]
+ );
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('correctly group data after flip (2)', async function (assert) {
+ assert.expect(5);
+ const actionManager = await createActionManager({
+ data: this.data,
+ archs: {
+ 'partner,false,pivot': "<pivot/>",
+ 'partner,false,search': `<search><filter name="bayou" string="Bayou" domain="[(1,'=',1)]"/></search>`,
+ 'partner,false,list': '<tree><field name="foo"/></tree>',
+ 'partner,false,form': '<form><field name="foo"/></form>',
+ },
+ });
+
+ await actionManager.doAction({
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'pivot']],
+ context: { group_by: ["product_id"] },
+ });
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // select filter "Bayou" in control panel
+ await cpHelpers.toggleFilterMenu(actionManager);
+ await cpHelpers.toggleMenuItem(actionManager, "Bayou");
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // flip axis
+ await testUtils.dom.click(actionManager.el.querySelector('.o_cp_buttons .o_pivot_flip_button'));
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total"
+ ]
+ );
+
+ // unselect filter "Bayou" in control panel
+ await cpHelpers.toggleFilterMenu(actionManager);
+ await cpHelpers.toggleMenuItem(actionManager, "Bayou");
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // close row header "Total"
+ await testUtils.dom.click(actionManager.el.querySelector('tbody .o_pivot_header_cell_opened'));
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...actionManager.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total"
+ ]
+ );
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('correctly group data after flip (3))', async function (assert) {
+ assert.expect(10);
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+ <pivot>
+ <field name="product_id" type="row"/>
+ <field name="company_type" type="col"/>
+ </pivot>
+ `,
+ archs: {
+ 'partner,false,search': `<search><filter name="bayou" string="Bayou" domain="[(1,'=',1)]"/></search>`,
+ }
+ });
+
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText),
+ [
+ "", "Total", "",
+ "Company", "individual",
+ "Count", "Count", "Count"
+ ]
+ );
+
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // close col header "Total"
+ await testUtils.dom.click(pivot.el.querySelector('thead .o_pivot_header_cell_opened'));
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText),
+ [
+ "", "Total",
+ "Count"
+ ]
+ );
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // flip axis
+ await testUtils.dom.click(pivot.el.querySelector('.o_cp_buttons .o_pivot_flip_button'));
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText),
+ [
+ "", "Total", "",
+ "xphone", "xpad",
+ "Count", "Count", "Count"
+ ]
+ );
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total"
+ ]
+ );
+
+ // select filter "Bayou" in control panel
+ await cpHelpers.toggleFilterMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, "Bayou");
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText),
+ [
+ "", "Total", "",
+ "xphone", "xpad",
+ "Count", "Count", "Count"
+ ]
+ );
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total",
+ "xphone",
+ "xpad"
+ ]
+ );
+
+ // close row header "Total"
+ await testUtils.dom.click(pivot.el.querySelector('tbody .o_pivot_header_cell_opened'));
+ await testUtils.nextTick();
+
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("thead th")].map(e => e.innerText),
+ [
+ "", "Total", "",
+ "xphone", "xpad",
+ "Count", "Count", "Count"
+ ]
+ );
+ assert.deepEqual(
+ [...pivot.el.querySelectorAll("tbody th")].map(e => e.innerText),
+ [
+ "Total"
+ ]
+ );
+
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly uses pivot_ keys from the context (at reload)', async function (assert) {
+ assert.expect(8);
+
+ this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'};
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="day" type="col"/>' +
+ '<field name="amount" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.strictEqual(pivot.$('tbody tr:first td.o_pivot_cell_value:last').text(), '0.00',
+ "the active measure should be amount");
+
+ var reloadParams = {
+ context: {
+ pivot_measures: ['foo'],
+ pivot_column_groupby: ['customer'],
+ pivot_row_groupby: ['product_id'],
+ },
+ };
+ await testUtils.pivot.reload(pivot, reloadParams);
+
+ assert.containsOnce(pivot, 'thead .o_pivot_header_cell_opened',
+ "column: should have one opened header");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1,
+ "column: should display one closed header with 'First'");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1,
+ "column: should display one closed header with 'Second'");
+
+ assert.containsOnce(pivot, 'tbody .o_pivot_header_cell_opened',
+ "row: should have one opened header");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1,
+ "row: should display one closed header with 'xphone'");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1,
+ "row: should display one closed header with 'xpad'");
+
+ assert.strictEqual(pivot.$('tbody tr:first td:nth(2)').text(), '32',
+ "selected measure should be foo, with total 32");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly use group_by key from the context', async function (assert) {
+ assert.expect(7);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="customer" type="col" />' +
+ '<field name="foo" type="measure" />' +
+ '</pivot>',
+ groupBy: ['product_id'],
+ });
+
+ assert.containsOnce(pivot, 'thead .o_pivot_header_cell_opened',
+ 'column: should have one opened header');
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1,
+ 'column: should display one closed header with "First"');
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1,
+ 'column: should display one closed header with "Second"');
+
+ assert.containsOnce(pivot, 'tbody .o_pivot_header_cell_opened',
+ 'row: should have one opened header');
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1,
+ 'row: should display one closed header with "xphone"');
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1,
+ 'row: should display one closed header with "xpad"');
+
+ assert.strictEqual(pivot.$('tbody tr:first td:nth(2)').text(), '32',
+ 'selected measure should be foo, with total 32');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly uses pivot_row_groupby key with default groupBy from the context', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.fields.amount = {string: "Amount", type: "float", group_operator: 'sum'};
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="customer" type="col"/>' +
+ '<field name="date" interval="day" type="row"/>' +
+ '</pivot>',
+ groupBy: ['customer'],
+ viewOptions: {
+ context: {
+ pivot_row_groupby: ['product_id'],
+ },
+ },
+ });
+
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_opened').length, 1,
+ "column: should have one opened header");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1,
+ "column: should display one closed header with 'First'");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1,
+ "column: should display one closed header with 'Second'");
+
+ // With pivot_row_groupby, groupBy customer should replace and eventually display product_id
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_opened').length, 1,
+ "row: should have one opened header");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1,
+ "row: should display one closed header with 'xphone'");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1,
+ "row: should display one closed header with 'xpad'");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot still handles __count__ measure', async function (assert) {
+ // for retro-compatibility reasons, the pivot view still handles
+ // '__count__' measure.
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot></pivot>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ assert.deepEqual(args.kwargs.fields, ['__count'],
+ "should make a read_group with field __count");
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ context: {
+ pivot_measures: ['__count__'],
+ },
+ },
+ });
+
+ var $countMeasure = pivot.$buttons.find('.dropdown-item[data-field=__count]:first');
+ assert.hasClass($countMeasure,'selected', "The count measure should be activated");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('not use a many2one as a measure by default', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id"/>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '</pivot>',
+ });
+ assert.notOk(pivot.measures.product_id,
+ "should not have product_id as measure");
+ pivot.destroy();
+ });
+
+ QUnit.test('use a many2one as a measure with specified additional measure', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id"/>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '</pivot>',
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+ assert.ok(pivot.measures.product_id,
+ "should have product_id as measure");
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view with many2one field as a measure', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="measure"/>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '</pivot>',
+ });
+
+ assert.strictEqual(pivot.$('table tbody tr').text().trim(), "Total2112",
+ "should display product_id count as measure");
+ pivot.destroy();
+ });
+
+ QUnit.test('m2o as measure, drilling down into data', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="measure"/>' +
+ '</pivot>',
+ });
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
+ // click on date by month
+ pivot.$('.dropdown-menu.show .o_inline_dropdown .dropdown-menu').toggle(); // unfold inline dropdown
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="date"]:contains("Month")'));
+
+ assert.strictEqual(pivot.$('.o_pivot_cell_value').text(), '2211',
+ 'should have loaded the proper data');
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view with same many2one field as a measure and grouped by', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '</pivot>',
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+
+ await testUtils.pivot.toggleMeasuresDropdown(pivot);
+ await testUtils.pivot.clickMeasure(pivot, 'product_id');
+ assert.strictEqual(pivot.$('.o_pivot_cell_value').text(), '421131',
+ 'should have loaded the proper data');
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view with same many2one field as a measure and grouped by (and drill down)', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="measure"/>' +
+ '</pivot>',
+ });
+
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
+
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first'));
+
+ assert.strictEqual(pivot.$('.o_pivot_cell_value').text(), '211',
+ 'should have loaded the proper data');
+ pivot.destroy();
+ });
+
+ QUnit.test('Row and column groupbys plus a domain', async function (assert) {
+ assert.expect(3);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ // Set a column groupby
+ await testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=customer]:first'));
+
+ // Set a Row groupby
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first'));
+
+ // Set a domain
+ await testUtils.pivot.reload(pivot, {domain: [['product_id', '=', 41]]});
+
+ var expectedContext = {
+ context: {
+ pivot_column_groupby: ['customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ },
+ };
+
+ // Mock 'save as favorite'
+ assert.deepEqual(pivot.getOwnedQueryParams(), expectedContext,
+ 'The pivot view should have the right context');
+
+ var $xpadHeader = pivot.$('tbody .o_pivot_header_cell_closed[data-original-title=Product]');
+ assert.equal($xpadHeader.length, 1,
+ 'There should be only one product line because of the domain');
+
+ assert.equal($xpadHeader.text(), 'xpad',
+ 'The product should be the right one');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('parallel data loading should discard all but the last one', async function (assert) {
+ assert.expect(2);
+
+ var def;
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'read_group') {
+ return Promise.resolve(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ def = testUtils.makeTestPromise();
+ testUtils.pivot.reload(pivot, {groupBy: ['product_id']});
+ testUtils.pivot.reload(pivot, {groupBy: ['product_id', 'customer']});
+ await def.resolve();
+ await testUtils.nextTick();
+ assert.containsN(pivot, '.o_pivot_cell_value', 6,
+ "should have 6 cells");
+ assert.containsN(pivot, 'tbody tr', 6,
+ "should have 6 rows");
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot measures should be alphabetically sorted', async function (assert) {
+ assert.expect(2);
+
+ var data = this.data;
+ // It's important to compare capitalized and lowercased words
+ // to be sure the sorting is effective with both of them
+ data.partner.fields.bouh = {string: "bouh", type: "integer", group_operator: 'sum'};
+ data.partner.fields.modd = {string: "modd", type: "integer", group_operator: 'sum'};
+ data.partner.fields.zip = {string: "Zip", type: "integer", group_operator: 'sum'};
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: data,
+ arch: '<pivot>' +
+ '<field name="zip" type="measure"/>' +
+ '<field name="foo" type="measure"/>' +
+ '<field name="bouh" type="measure"/>' +
+ '<field name="modd" type="measure"/>' +
+ '</pivot>',
+ });
+ assert.strictEqual(pivot.$buttons.find('.o_pivot_measures_list .dropdown-item:first').data('field'), 'bouh',
+ "Bouh should be the first measure");
+ assert.strictEqual(pivot.$buttons.find('.o_pivot_measures_list .dropdown-item:last').data('field'), '__count',
+ "Count should be the last measure");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view should use default order for auto sorting', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot default_order="foo asc">' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.hasClass(pivot.$('thead tr:last th:last'), 'o_pivot_sort_order_asc',
+ "Last thead should be sorted in ascending order");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view can be flipped', async function (assert) {
+ assert.expect(5);
+
+ var rpcCount = 0;
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '</pivot>',
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(pivot, 'tbody tr', 3,
+ "should have 3 rows: 1 for the open header, and 2 for data");
+ var values = [
+ "4",
+ "1",
+ "3"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ rpcCount = 0;
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button'));
+
+ assert.strictEqual(rpcCount, 0, "should not have done any rpc");
+ assert.containsOnce(pivot, 'tbody tr',
+ "should have 1 rows: 1 for the main header");
+
+ values = [
+ "1", "3", "4"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ pivot.destroy();
+ });
+
+ QUnit.test('rendering of pivot view with comparison', async function (assert) {
+ assert.expect(8);
+
+ this.data.partner.records[0].date = '2016-12-15';
+ this.data.partner.records[1].date = '2016-12-17';
+ this.data.partner.records[2].date = '2016-11-22';
+ this.data.partner.records[3].date = '2016-11-03';
+
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='last_year'/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ context: { search_default_date_filter: 1 },
+ },
+ mockRPC: function () {
+ return this._super.apply(this, arguments);
+ },
+ env: {
+ dataManager: {
+ create_filter: async function (filter) {
+ assert.deepEqual(filter.context, {
+ pivot_measures: ['__count'],
+ pivot_column_groupby: [],
+ pivot_row_groupby: ['product_id'],
+ group_by: [],
+ comparison: {
+ comparisonId: "previous_period",
+ comparisonRange: "[\"&\", [\"date\", \">=\", \"2016-11-01\"], [\"date\", \"<=\", \"2016-11-30\"]]",
+ comparisonRangeDescription: "November 2016",
+ fieldDescription: "Date",
+ fieldName: "date",
+ range: "[\"&\", [\"date\", \">=\", \"2016-12-01\"], [\"date\", \"<=\", \"2016-12-31\"]]",
+ rangeDescription: "December 2016"
+ },
+ });
+ }
+ }
+ },
+ });
+
+ // with no data
+ await cpHelpers.toggleComparisonMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period');
+
+ assert.strictEqual(pivot.$('.o_pivot p.o_view_nocontent_empty_folder').length, 1);
+
+ await cpHelpers.toggleFilterMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date');
+ await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'December');
+ await cpHelpers.toggleMenuItemOption(pivot, 'Date', '2016');
+ await cpHelpers.toggleMenuItemOption(pivot, 'Date', '2015');
+
+ assert.containsN(pivot, '.o_pivot thead tr:last th', 9,
+ "last header row should contains 9 cells (3*[December 2016, November 2016, Variation]");
+ var values = [
+ "19", "0", "-100%", "0", "13", "100%", "19", "13", "-31.58%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ // with data, with row groupby
+ await testUtils.dom.click(pivot.$('.o_pivot .o_pivot_header_cell_closed').eq(2));
+ await testUtils.dom.click(pivot.$('.o_pivot .o_pivot_field_menu a[data-field="product_id"]'));
+ values = [
+ "19", "0", "-100%", "0", "13", "100%", "19", "13", "-31.58%",
+ "19", "0", "-100%", "0", "1" , "100%", "19", "1", "-94.74%",
+ "0", "12", "100%", "0" , "12", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ await testUtils.pivot.toggleMeasuresDropdown(pivot);
+ await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="foo"]'));
+ await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="product_id"]'));
+ values = [
+ "1", "0", "-100%", "0", "2", "100%", "1", "2", "100%",
+ "1", "0", "-100%", "0", "1", "100%", "1", "1", "0%",
+ "0", "1", "100%", "0", "1", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="__count"]'));
+ await testUtils.dom.click(pivot.$('.o_control_panel div.o_pivot_measures_list a[data-field="product_id"]'));
+ values = [
+ "2", "0", "-100%", "0", "2", "100%", "2", "2", "0%",
+ "2", "0", "-100%", "0", "1", "100%", "2", "1", "-50%",
+ "0", "1", "100%", "0", "1", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ await testUtils.dom.clickFirst(pivot.$('.o_pivot .o_pivot_header_cell_opened'));
+ values = [
+ "2", "2", "0%",
+ "2", "1", "-50%",
+ "0", "1", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ await cpHelpers.toggleFavoriteMenu(pivot);
+ await cpHelpers.toggleSaveFavorite(pivot);
+ await cpHelpers.editFavoriteName(pivot, 'Fav');
+ await cpHelpers.saveFavorite(pivot);
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('export data in excel with comparison', async function (assert) {
+ assert.expect(11);
+
+ this.data.partner.records[0].date = '2016-12-15';
+ this.data.partner.records[1].date = '2016-12-17';
+ this.data.partner.records[2].date = '2016-11-22';
+ this.data.partner.records[3].date = '2016-11-03';
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="month" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='antepenultimate_month'/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ context: { search_default_date_filter: 1 },
+ },
+ session: {
+ get_file: function (args) {
+ var data = JSON.parse(args.data.data);
+ _.each(data.col_group_headers, function (l) {
+ var titles = l.map(function (o) {
+ return o.title;
+ });
+ assert.step(JSON.stringify(titles));
+ });
+ var measures = data.measure_headers.map(function (o) {
+ return o.title;
+ });
+ assert.step(JSON.stringify(measures));
+ var origins = data.origin_headers.map(function (o) {
+ return o.title;
+ });
+ assert.step(JSON.stringify(origins));
+ assert.step(String(data.measure_count));
+ assert.step(String(data.origin_count));
+ var valuesLength = data.rows.map(function (o) {
+ return o.values.length;
+ });
+ assert.step(JSON.stringify(valuesLength));
+ assert.strictEqual(args.url, '/web/pivot/export_xlsx',
+ "should call get_file with correct parameters");
+ args.complete();
+ },
+ },
+ });
+
+ // open comparison menu
+ await cpHelpers.toggleComparisonMenu(pivot);
+ // compare October 2016 to September 2016
+ await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period');
+
+ // With the data above, the time ranges contain no record.
+ assert.strictEqual(pivot.$('.o_pivot p.o_view_nocontent_empty_folder').length, 1, "there should be no data");
+ // export data should be impossible since the pivot buttons
+ // are deactivated (exception: the 'Measures' button).
+ assert.ok(pivot.$('.o_control_panel button.o_pivot_download').prop('disabled'));
+
+ await cpHelpers.toggleFilterMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date');
+ await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'December');
+ await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'October');
+
+ // With the data above, the time ranges contain some records.
+ // export data. Should execute 'get_file'
+ await testUtils.dom.click(pivot.$('.o_control_panel button.o_pivot_download'));
+
+ assert.verifySteps([
+ // col group headers
+ '["Total",""]',
+ '["November 2016","December 2016"]',
+ // measure headers
+ '["Foo","Foo","Foo"]',
+ // origin headers
+ '["November 2016","December 2016","Variation","November 2016","December 2016"' +
+ ',"Variation","November 2016","December 2016","Variation"]',
+ // number of 'measures'
+ '1',
+ // number of 'origins'
+ '2',
+ // rows values length
+ '[9]',
+ ]);
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('rendering of pivot view with comparison and count measure', async function (assert) {
+ assert.expect(2);
+
+ var mockMock = false;
+ var nbReadGroup = 0;
+
+ this.data.partner.records[0].date = '2016-12-15';
+ this.data.partner.records[1].date = '2016-12-17';
+ this.data.partner.records[2].date = '2016-12-22';
+ this.data.partner.records[3].date = '2016-12-03';
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot><field name="customer" type="row"/></pivot>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='this_month'/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ context: { search_default_date_filter: 1 },
+ },
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'read_group' && mockMock) {
+ nbReadGroup++;
+ if (nbReadGroup === 4) {
+ // this modification is necessary because mockReadGroup does not
+ // properly reflect the server response when there is no record
+ // and a groupby list of length at least one.
+ return Promise.resolve([{}]);
+ }
+ }
+ return result;
+ },
+ });
+
+ mockMock = true;
+
+ // compare December 2016 to November 2016
+ await cpHelpers.toggleComparisonMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period');
+
+ var values = [
+ "0", "4", "100%",
+ "0", "2", "100%",
+ "0", "2", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join(','));
+ assert.strictEqual(pivot.$('.o_pivot_header_cell_closed').length, 3,
+ "there should be exactly three closed header ('Total','First', 'Second')");
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('can sort a pivot view with comparison by clicking on header', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records[0].date = '2016-12-15';
+ this.data.partner.records[1].date = '2016-12-17';
+ this.data.partner.records[2].date = '2016-11-22';
+ this.data.partner.records[3].date = '2016-11-03';
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="day" type="row"/>' +
+ '<field name="company_type" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='this_month'/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ context: { search_default_date_filter: 1 },
+ },
+ });
+
+ // compare December 2016 to November 2016
+ await cpHelpers.toggleComparisonMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period');
+
+ // initial sanity check
+ var values = [
+ "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%",
+ "17", "0", "-100%", "17", "0", "-100%",
+ "2", "0", "-100%", "2", "0", "-100%",
+ "0", "12" , "100%", "0", "12" , "100%",
+ "0", "1", "100%" , "0" , "1" , "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ // click on 'Foo' in column Total/Company (should sort by the period of interest, ASC)
+ await testUtils.dom.click(pivot.$('.o_pivot_measure_row').eq(0));
+ values = [
+ "17", "12", "-29.41%", "2", "1", "-50%" , "19", "13", "-31.58%",
+ "2", "0", "-100%", "2", "0", "-100%",
+ "0", "12", "100%", "0", "12", "100%",
+ "0", "1", "100%", "0", "1", "100%",
+ "17", "0", "-100%", "17", "0", "-100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ // click again on 'Foo' in column Total/Company (should sort by the period of interest, DESC)
+ await testUtils.dom.click(pivot.$('.o_pivot_measure_row').eq(0));
+ values = [
+ "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%",
+ "17", "0", "-100%", "17", "0", "-100%",
+ "2", "0", "-100%", "2", "0", "-100%",
+ "0", "12", "100%", "0", "12", "100%",
+ "0", "1", "100%", "0", "1", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ // click on 'This Month' in column Total/Individual/Foo
+ await testUtils.dom.click(pivot.$('.o_pivot_origin_row').eq(3));
+ values = [
+ "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%",
+ "17", "0", "-100%", "17", "0", "-100%",
+ "0", "12", "100%", "0", "12" , "100%",
+ "0", "1", "100%", "0", "1", "100%",
+ "2", "0", "-100%", "2", "0", "-100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ // click on 'Previous Period' in column Total/Individual/Foo
+ await testUtils.dom.click(pivot.$('.o_pivot_origin_row').eq(4));
+ values = [
+ "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%",
+ "17", "0", "-100%", "17", "0", "-100%",
+ "2", "0", "-100%", "2", "0", "-100%",
+ "0", "12", "100%", "0", "12", "100%",
+ "0", "1", "100%", "0", "1", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ // click on 'Variation' in column Total/Foo
+ await testUtils.dom.click(pivot.$('.o_pivot_origin_row').eq(8));
+ values = [
+ "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%",
+ "17", "0", "-100%", "17", "0", "-100%",
+ "2", "0", "-100%", "2" , "0" , "-100%",
+ "0", "12", "100%", "0", "12" , "100%",
+ "0", "1", "100%", "0" , "1", "100%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('Click on the measure list but not on a menu item', async function (assert) {
+ assert.expect(2);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `<pivot/>`,
+ });
+
+ // open the "Measures" menu
+ await testUtils.dom.click(pivot.el.querySelector('.o_cp_buttons button'));
+
+ // click on the divider in the "Measures" menu does not crash
+ await testUtils.dom.click(pivot.el.querySelector('.o_pivot_measures_list .dropdown-divider'));
+ // the menu should still be open
+ assert.isVisible(pivot.el.querySelector('.o_pivot_measures_list'));
+
+ // click on the measure list but not on a menu item or the separator
+ await testUtils.dom.click(pivot.el.querySelector('.o_pivot_measures_list'));
+ // the menu should still be open
+ assert.isVisible(pivot.el.querySelector('.o_pivot_measures_list'));
+
+ pivot.destroy();
+ });
+
+ QUnit.test('Navigation list view for a group and back with breadcrumbs', async function (assert) {
+ assert.expect(16);
+ // create an action manager to test the interactions with the search view
+ var readGroupCount = 0;
+
+ var actionManager = await createActionManager({
+ data: this.data,
+ archs: {
+ 'partner,false,pivot': '<pivot>' +
+ '<field name="customer" type="row"/>' +
+ '</pivot>',
+ 'partner,false,search': '<search><filter name="bayou" string="Bayou" domain="[(\'foo\',\'=\', 12)]"/></search>',
+ 'partner,false,list': '<tree><field name="foo"/></tree>',
+ 'partner,false,form': '<form><field name="foo"/></form>',
+ },
+ intercepts: {
+ do_action: function (event) {
+ var action = event.data.action;
+ actionManager.doAction(action);
+ }
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ assert.step('read_group');
+ const domain = args.kwargs.domain;
+ if ([0,1].indexOf(readGroupCount) !== -1) {
+ assert.deepEqual(domain, [], 'domain empty');
+ } else if ([2,3,4,5].indexOf(readGroupCount) !== -1) {
+ assert.deepEqual(domain, [['foo', '=', 12]],
+ 'domain conserved when back with breadcrumbs');
+ }
+ readGroupCount++;
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.step('search_read');
+ const domain = args.domain;
+ assert.deepEqual(domain, ['&', ['customer', '=', 1], ['foo', '=', 12]],
+ 'list domain is correct');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await actionManager.doAction({
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'pivot']],
+ });
+
+
+ await cpHelpers.toggleFilterMenu(actionManager);
+ await cpHelpers.toggleMenuItem(actionManager, 0);
+ await testUtils.nextTick();
+
+ await testUtilsDom.click(actionManager.$('.o_pivot_cell_value:nth(1)'));
+ await testUtils.nextTick();
+
+ assert.containsOnce(actionManager, '.o_list_view');
+
+ await testUtilsDom.click(actionManager.$('.o_control_panel ol.breadcrumb li.breadcrumb-item').eq(0));
+
+ assert.verifySteps([
+ 'read_group', 'read_group',
+ 'read_group', 'read_group',
+ 'search_read',
+ 'read_group', 'read_group']);
+ actionManager.destroy();
+ });
+
+ QUnit.test('Cell values are kept when flippin a pivot view in comparison mode', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].date = '2016-12-15';
+ this.data.partner.records[1].date = '2016-12-17';
+ this.data.partner.records[2].date = '2016-11-22';
+ this.data.partner.records[3].date = '2016-11-03';
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="day" type="row"/>' +
+ '<field name="company_type" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='this_month'/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ context: { search_default_date_filter: 1 },
+ },
+ });
+
+ // compare December 2016 to November 2016
+ await cpHelpers.toggleComparisonMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period');
+
+ // initial sanity check
+ var values = [
+ "17", "12", "-29.41%", "2", "1", "-50%", "19", "13", "-31.58%",
+ "17", "0", "-100%", "17", "0", "-100%",
+ "2", "0", "-100%", "2", "0", "-100%",
+ "0", "12", "100%", "0", "12", "100%",
+ "0", "1", "100%", "0", "1", "100%",
+
+
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ // flip table
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button'));
+
+ values = [
+ "17", "0", "-100%", "2", "0", "-100%", "0", "12", "100%", "0", "1", "100%", "19", "13", "-31.58%",
+ "17", "0", "-100%", "0", "12", "100%", "17", "12", "-29.41%",
+ "2", "0", "-100%", "0", "1", "100%", "2", "1", "-50%"
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('Flip then compare, table col groupbys are kept', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records[0].date = '2016-12-15';
+ this.data.partner.records[1].date = '2016-12-17';
+ this.data.partner.records[2].date = '2016-11-22';
+ this.data.partner.records[3].date = '2016-11-03';
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" interval="day" type="row"/>' +
+ '<field name="company_type" type="col"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="date_filter" date="date" domain="[]" default_period='this_month'/>
+ </search>
+ `,
+ },
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 5).text(),
+ [
+ '', 'Total', '',
+ 'Company', 'individual',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(8).text(),
+ [
+ 'Total',
+ '2016-12-15',
+ '2016-12-17',
+ '2016-11-22',
+ '2016-11-03'
+ ].join(''),
+ "The row headers should be as expected"
+ );
+
+ // flip
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button'));
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 7).text(),
+ [
+ '', 'Total', '',
+ '2016-12-15', '2016-12-17', '2016-11-22', '2016-11-03',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(12).text(),
+ [
+ 'Total',
+ 'Company',
+ 'individual'
+
+ ].join(''),
+ "The row headers should be as expected"
+ );
+
+ // Filter on December 2016
+ await cpHelpers.toggleFilterMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date');
+ await cpHelpers.toggleMenuItemOption(pivot, 'Date', 'December');
+
+ // compare December 2016 to November 2016
+ await cpHelpers.toggleComparisonMenu(pivot);
+ await cpHelpers.toggleMenuItem(pivot, 'Date: Previous period');
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 7).text(),
+ [
+ '', 'Total', '',
+ '2016-11-22', '2016-11-03', '2016-12-15', '2016-12-17',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(27).text(),
+ [
+ 'Total',
+ 'Company',
+ 'individual'
+
+ ].join(''),
+ "The row headers should be as expected"
+ );
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly compute group domain when a date field has false value', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records.forEach(r => r.date = false);
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot o_enable_linking="1">' +
+ '<field name="date" interval="day" type="row"/>' +
+ '</pivot>',
+ intercepts: {
+ do_action: function (ev) {
+ assert.deepEqual(ev.data.action.domain, [['date', '=', false]]);
+ },
+ },
+ });
+
+ await testUtils.dom.click($('div .o_value')[1]);
+
+ unpatchDate();
+ pivot.destroy();
+ });
+ QUnit.test('Does not identify "false" with false as keys when creating group trees', async function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.favorite_animal = {string: "Favorite animal", type: "char", store: true};
+ this.data.partner.records[0].favorite_animal = 'Dog';
+ this.data.partner.records[1].favorite_animal = 'false';
+ this.data.partner.records[2].favorite_animal = 'Undefined';
+
+ var unpatchDate = patchDate(2016, 11, 20, 1, 0, 0);
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot o_enable_linking="1">' +
+ '<field name="favorite_animal" type="row"/>' +
+ '</pivot>',
+
+ });
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 2).text(),
+ [
+ '', 'Total', '',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(3).text(),
+ [
+ 'Total',
+ 'Dog',
+ 'false',
+ 'Undefined',
+ 'Undefined'
+
+ ].join(''),
+ "The row headers should be as expected"
+ );
+
+ unpatchDate();
+ pivot.destroy();
+ });
+
+ QUnit.test('group bys added via control panel and expand Header do not stack', async function (assert) {
+ assert.expect(8);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 2).text(),
+ [
+ '', 'Total',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(3).text(),
+ [
+ 'Total',
+ ].join(''),
+ "The row headers should be as expected"
+ );
+
+
+ // open group by menu and add new groupby
+ await cpHelpers.toggleGroupByMenu(pivot);
+ await cpHelpers.toggleAddCustomGroup(pivot);
+ await cpHelpers.applyGroup(pivot);
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 2).text(),
+ [
+ '', 'Total',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(3).text(),
+ [
+ 'Total',
+ 'Company',
+ 'individual'
+ ].join(''),
+ "The row headers should be as expected"
+ );
+
+ // Set a Row groupby
+ await testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed').eq(0));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first'));
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 2).text(),
+ [
+ '', 'Total',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(3).text(),
+ [
+ 'Total',
+ 'Company',
+ 'xphone',
+ 'xpad',
+ 'individual'
+ ].join(''),
+ "The row headers should be as expected"
+ );
+
+ // open groupby menu generator and add a new groupby
+ await cpHelpers.toggleGroupByMenu(pivot);
+ await cpHelpers.toggleAddCustomGroup(pivot);
+ await cpHelpers.selectGroup(pivot, 'bar');
+ await cpHelpers.applyGroup(pivot);
+
+ assert.strictEqual(
+ pivot.$('th').slice(0, 2).text(),
+ [
+ '', 'Total',
+ ].join(''),
+ "The col headers should be as expected"
+ );
+ assert.strictEqual(
+ pivot.$('th').slice(3).text(),
+ [
+ 'Total',
+ 'Company',
+ 'true',
+ 'individual',
+ 'true',
+ 'Undefined'
+ ].join(''),
+ "The row headers should be as expected"
+ );
+
+ pivot.destroy();
+ });
+
+ QUnit.test('display only one dropdown menu', async function (assert) {
+ assert.expect(1);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: 'partner',
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ viewOptions: {
+ additionalMeasures: ['product_id'],
+ },
+ });
+ await testUtils.dom.clickFirst(pivot.$('th.o_pivot_header_cell_closed'));
+ await testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field=product_id]:first'));
+
+ // Click on the two dropdown
+ await testUtils.dom.click(pivot.$('th.o_pivot_header_cell_closed')[0]);
+ await testUtils.dom.click(pivot.$('th.o_pivot_header_cell_closed')[1]);
+
+ assert.containsOnce(pivot, '.o_pivot_field_menu', 'Only one dropdown should be displayed at a time');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('Server order is kept by default', async function (assert) {
+ assert.expect(1);
+
+ let isSecondReadGroup = false;
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="customer" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group' && isSecondReadGroup) {
+ return Promise.resolve([
+ {
+ customer: [2, 'Second'],
+ foo: 18,
+ __count: 2,
+ __domain :[["customer", "=", 2]],
+ },
+ {
+ customer: [1, 'First'],
+ foo: 14,
+ __count: 2,
+ __domain :[["customer", "=", 1]],
+ }
+ ]);
+ }
+ var result = this._super.apply(this, arguments);
+ isSecondReadGroup = true;
+ return result;
+ },
+ });
+
+ const values = [
+ "32", // Total Value
+ "18", // Second
+ "14", // First
+ ];
+ assert.strictEqual(getCurrentValues(pivot), values.join());
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot rendering with boolean field', async function (assert) {
+ assert.expect(4);
+
+ this.data.partner.fields.bar = {string: "bar", type: "boolean", store: true, searchable: true, group_operator: 'bool_or'};
+ this.data.partner.records = [{id: 1, bar: true, date: '2019-12-14'}, {id: 2, bar: false, date: '2019-05-14'}];
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" type="row" interval="day"/>' +
+ '<field name="bar" type="col"/>' +
+ '<field name="bar" string="SLA status Failed" type="measure"/>' +
+ '</pivot>',
+ });
+
+ assert.strictEqual(pivot.$('tbody tr:contains("2019-12-14")').length, 1, 'There should be a first column');
+ assert.ok(pivot.$('tbody tr:contains("2019-12-14") [type="checkbox"]').is(':checked'), 'first column contains checkbox and value should be ticked');
+ assert.strictEqual(pivot.$('tbody tr:contains("2019-05-14")').length, 1, 'There should be a second column');
+ assert.notOk(pivot.$('tbody tr:contains("2019-05-14") [type="checkbox"]').is(':checked'), "second column should have checkbox that is not checked by default");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('Allow to add behaviour to buttons on pivot', async function (assert) {
+ assert.expect(2);
+
+ let _testButtons = (ev) => {
+ if ($(ev.target).hasClass("o_pivot_flip_button")) {
+ assert.step("o_pivot_flip_button")
+ }
+ }
+
+ PivotController.include({
+ _addIncludedButtons: async function (ev) {
+ await this._super(...arguments);
+ _testButtons(ev);
+ },
+ });
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="date" type="row" interval="day"/>' +
+ '<field name="bar" type="col"/>' +
+ '</pivot>',
+ });
+ await testUtils.dom.click(pivot.$buttons.find('.o_pivot_flip_button'));
+ assert.verifySteps(["o_pivot_flip_button"]);
+ _testButtons = () => true;
+ pivot.destroy();
+ });
+
+ QUnit.test('empty pivot view with action helper', async function (assert) {
+ assert.expect(4);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+ <pivot>
+ <field name="product_id" type="measure"/>
+ <field name="date" interval="month" type="col"/>
+ </pivot>`,
+ domain: [['id', '<', 0]],
+ viewOptions: {
+ action: {
+ help: '<p class="abc">click to add a foo</p>'
+ }
+ },
+ });
+
+ assert.containsOnce(pivot, '.o_view_nocontent .abc');
+ assert.containsNone(pivot, 'table');
+
+ await pivot.reload({ domain: [] });
+
+ assert.containsNone(pivot, '.o_view_nocontent .abc');
+ assert.containsOnce(pivot, 'table');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('empty pivot view with sample data', async function (assert) {
+ assert.expect(7);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+ <pivot sample="1">
+ <field name="product_id" type="measure"/>
+ <field name="date" interval="month" type="col"/>
+ </pivot>`,
+ domain: [['id', '<', 0]],
+ viewOptions: {
+ action: {
+ help: '<p class="abc">click to add a foo</p>'
+ }
+ },
+ });
+
+ assert.hasClass(pivot.el, 'o_view_sample_data');
+ assert.containsOnce(pivot, '.o_view_nocontent .abc');
+ assert.containsOnce(pivot, 'table.o_sample_data_disabled');
+
+ await pivot.reload({ domain: [] });
+
+ assert.doesNotHaveClass(pivot.el, 'o_view_sample_data');
+ assert.containsNone(pivot, '.o_view_nocontent .abc');
+ assert.containsOnce(pivot, 'table');
+ assert.doesNotHaveClass(pivot.$('table'), 'o_sample_data_disabled');
+
+ pivot.destroy();
+ });
+
+ QUnit.test('non empty pivot view with sample data', async function (assert) {
+ assert.expect(7);
+
+ const pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: `
+ <pivot sample="1">
+ <field name="product_id" type="measure"/>
+ <field name="date" interval="month" type="col"/>
+ </pivot>`,
+ viewOptions: {
+ action: {
+ help: '<p class="abc">click to add a foo</p>'
+ }
+ },
+ });
+
+ assert.doesNotHaveClass(pivot.el, 'o_view_sample_data');
+ assert.containsNone(pivot, '.o_view_nocontent .abc');
+ assert.containsOnce(pivot, 'table');
+ assert.doesNotHaveClass(pivot.$('table'), 'o_sample_data_disabled');
+
+ await pivot.reload({ domain: [['id', '<', 0]] });
+
+ assert.doesNotHaveClass(pivot.el, 'o_view_sample_data');
+ assert.containsOnce(pivot, '.o_view_nocontent .abc');
+ assert.containsNone(pivot, 'table');
+
+ pivot.destroy();
+ });
+});
+});
diff --git a/addons/web/static/tests/views/qweb_tests.js b/addons/web/static/tests/views/qweb_tests.js
new file mode 100644
index 00000000..19acf9b0
--- /dev/null
+++ b/addons/web/static/tests/views/qweb_tests.js
@@ -0,0 +1,72 @@
+odoo.define('web.qweb_view_tests', function (require) {
+"use strict";
+
+var utils = require('web.test_utils');
+
+QUnit.module("Views", {
+
+}, function () {
+ QUnit.module("QWeb");
+ QUnit.test("basic", async function (assert) {
+ assert.expect(14);
+ var am = await utils.createActionManager({
+ data: {
+ test: {
+ fields: {},
+ records: [],
+ }
+ },
+ archs: {
+ 'test,5,qweb': '<div id="xxx"><t t-esc="ok"/></div>',
+ 'test,false,search': '<search/>'
+ },
+ mockRPC: function (route, args) {
+ if (/^\/web\/dataset\/call_kw/.test(route)) {
+ switch (_.str.sprintf('%(model)s.%(method)s', args)) {
+ case 'test.qweb_render_view':
+ assert.step('fetch');
+ assert.equal(args.kwargs.view_id, 5);
+ return Promise.resolve(
+ '<div>foo' +
+ '<div data-model="test" data-method="wheee" data-id="42" data-other="5">' +
+ '<a type="toggle" class="fa fa-caret-right">Unfold</a>' +
+ '</div>' +
+ '</div>'
+ );
+ case 'test.wheee':
+ assert.step('unfold');
+ assert.deepEqual(args.args, [42]);
+ assert.deepEqual(args.kwargs, {other: 5});
+ return Promise.resolve('<div id="sub">ok</div>');
+ }
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+ try {
+ var resolved = false;
+ am.doAction({
+ type: 'ir.actions.act_window',
+ views: [[false, 'qweb']],
+ res_model: 'test',
+ }).then(function () { resolved = true; });
+ assert.ok(!resolved, "Action cannot be resolved synchronously");
+
+ await utils.nextTick();
+ assert.ok(resolved, "Action is resolved asynchronously");
+
+ var $content = am.$('.o_content');
+ assert.ok(/^\s*foo/.test($content.text()));
+ await utils.dom.click($content.find('[type=toggle]'));
+ assert.equal($content.find('div#sub').text(), 'ok', 'should have unfolded the sub-item');
+ await utils.dom.click($content.find('[type=toggle]'));
+ assert.equal($content.find('div#sub').length, 0, "should have removed the sub-item");
+ await utils.dom.click($content.find('[type=toggle]'));
+
+ assert.verifySteps(['fetch', 'unfold', 'unfold']);
+ } finally {
+ am.destroy();
+ }
+ });
+});
+});
diff --git a/addons/web/static/tests/views/sample_server_tests.js b/addons/web/static/tests/views/sample_server_tests.js
new file mode 100644
index 00000000..35e33e63
--- /dev/null
+++ b/addons/web/static/tests/views/sample_server_tests.js
@@ -0,0 +1,486 @@
+odoo.define('web.sample_server_tests', function (require) {
+ "use strict";
+
+ const SampleServer = require('web.SampleServer');
+ const session = require('web.session');
+ const { mock } = require('web.test_utils');
+
+ const {
+ MAIN_RECORDSET_SIZE, SEARCH_READ_LIMIT, // Limits
+ SAMPLE_COUNTRIES, SAMPLE_PEOPLE, SAMPLE_TEXTS, // Text values
+ MAX_COLOR_INT, MAX_FLOAT, MAX_INTEGER, MAX_MONETARY, // Number values
+ SUB_RECORDSET_SIZE, // Records sise
+ } = SampleServer;
+
+ /**
+ * Transforms random results into deterministic ones.
+ */
+ class DeterministicSampleServer extends SampleServer {
+ constructor() {
+ super(...arguments);
+ this.arrayElCpt = 0;
+ this.boolCpt = 0;
+ this.subRecordIdCpt = 0;
+ }
+ _getRandomArrayEl(array) {
+ return array[this.arrayElCpt++ % array.length];
+ }
+ _getRandomBool() {
+ return Boolean(this.boolCpt++ % 2);
+ }
+ _getRandomSubRecordId() {
+ return (this.subRecordIdCpt++ % SUB_RECORDSET_SIZE) + 1;
+ }
+ }
+
+ QUnit.module("Sample Server", {
+ beforeEach() {
+ this.fields = {
+ 'res.users': {
+ display_name: { string: "Name", type: 'char' },
+ name: { string: "Reference", type: 'char' },
+ email: { string: "Email", type: 'char' },
+ phone_number: { string: "Phone number", type: 'char' },
+ brol_machin_url_truc: { string: "URL", type: 'char' },
+ urlemailphone: { string: "Whatever", type: 'char' },
+ active: { string: "Active", type: 'boolean' },
+ is_alive: { string: "Is alive", type: 'boolean' },
+ description: { string: "Description", type: 'text' },
+ birthday: { string: "Birthday", type: 'date' },
+ arrival_date: { string: "Date of arrival", type: 'datetime' },
+ height: { string: "Height", type: 'float' },
+ color: { string: "Color", type: 'integer' },
+ age: { string: "Age", type: 'integer' },
+ salary: { string: "Salary", type: 'monetary' },
+ currency: { string: "Currency", type: 'many2one', relation: 'res.currency' },
+ manager_id: { string: "Manager", type: 'many2one', relation: 'res.users' },
+ cover_image_id: { string: "Cover Image", type: 'many2one', relation: 'ir.attachment' },
+ managed_ids: { string: "Managing", type: 'one2many', relation: 'res.users' },
+ tag_ids: { string: "Tags", type: 'many2many', relation: 'tag' },
+ type: { string: "Type", type: 'selection', selection: [
+ ['client', "Client"], ['partner', "Partner"], ['employee', "Employee"]
+ ] },
+ },
+ 'res.country': {
+ display_name: { string: "Name", type: 'char' },
+ },
+ 'hobbit': {
+ display_name: { string: "Name", type: 'char' },
+ profession: { string: "Profession", type: 'selection', selection: [
+ ['gardener', "Gardener"], ['brewer', "Brewer"], ['adventurer', "Adventurer"]
+ ] },
+ age: { string: "Age", type: 'integer' },
+ },
+ 'ir.attachment': {
+ display_name: { string: "Name", type: 'char' },
+ }
+ };
+ },
+ }, function () {
+
+ QUnit.module("Basic behaviour");
+
+ QUnit.test("Sample data: people type + all field names", async function (assert) {
+ assert.expect(26);
+
+ mock.patch(session, {
+ company_currency_id: 4,
+ });
+
+ const allFieldNames = Object.keys(this.fields['res.users']);
+ const server = new DeterministicSampleServer('res.users', this.fields['res.users']);
+ const { records } = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'res.users',
+ fields: allFieldNames,
+ });
+ const rec = records[0];
+
+ function assertFormat(fieldName, regex) {
+ if (regex instanceof RegExp) {
+ assert.ok(
+ regex.test(rec[fieldName].toString()),
+ `Field "${fieldName}" has the correct format`
+ );
+ } else {
+ assert.strictEqual(
+ typeof rec[fieldName], regex,
+ `Field "${fieldName}" is of type ${regex}`
+ );
+ }
+ }
+ function assertBetween(fieldName, min, max, isFloat = false) {
+ const val = rec[fieldName];
+ assert.ok(
+ min <= val && val < max && (isFloat || parseInt(val, 10) === val),
+ `Field "${fieldName}" is between ${min} and ${max} ${!isFloat ? 'and is an integer ' : ''}: ${val}`
+ );
+ }
+
+ // Basic fields
+ assert.ok(SAMPLE_PEOPLE.includes(rec.display_name));
+ assert.ok(SAMPLE_PEOPLE.includes(rec.name));
+ assert.strictEqual(rec.email,
+ `${rec.display_name.replace(/ /, ".").toLowerCase()}@sample.demo`
+ );
+ assertFormat('phone_number', /\+1 555 754 000\d/);
+ assertFormat('brol_machin_url_truc', /http:\/\/sample\d\.com/);
+ assert.strictEqual(rec.urlemailphone, false);
+ assert.strictEqual(rec.active, true);
+ assertFormat('is_alive', 'boolean');
+ assert.ok(SAMPLE_TEXTS.includes(rec.description));
+ assertFormat('birthday', /\d{4}-\d{2}-\d{2}/);
+ assertFormat('arrival_date', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
+ assertBetween('height', 0, MAX_FLOAT, true);
+ assertBetween('color', 0, MAX_COLOR_INT);
+ assertBetween('age', 0, MAX_INTEGER);
+ assertBetween('salary', 0, MAX_MONETARY);
+
+ // check float field have 2 decimal rounding
+ assert.strictEqual(rec.height, parseFloat(parseFloat(rec.height).toFixed(2)));
+
+ const selectionValues = this.fields['res.users'].type.selection.map(
+ (sel) => sel[0]
+ );
+ assert.ok(selectionValues.includes(rec.type));
+
+ // Relational fields
+ assert.strictEqual(rec.currency[0], 4);
+ // Currently we expect the currency name to be a latin string, which
+ // is not important; in most case we only need the ID. The following
+ // assertion can be removed if needed.
+ assert.ok(SAMPLE_TEXTS.includes(rec.currency[1]));
+
+ assert.strictEqual(typeof rec.manager_id[0], 'number');
+ assert.ok(SAMPLE_PEOPLE.includes(rec.manager_id[1]));
+
+ assert.strictEqual(rec.cover_image_id, false);
+
+ assert.strictEqual(rec.managed_ids.length, 2);
+ assert.ok(rec.managed_ids.every(
+ (id) => typeof id === 'number')
+ );
+
+ assert.strictEqual(rec.tag_ids.length, 2);
+ assert.ok(rec.tag_ids.every(
+ (id) => typeof id === 'number')
+ );
+
+ mock.unpatch(session);
+ });
+
+ QUnit.test("Sample data: country type", async function (assert) {
+ assert.expect(1);
+
+ const server = new DeterministicSampleServer('res.country', this.fields['res.country']);
+ const { records } = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'res.country',
+ fields: ['display_name'],
+ });
+
+ assert.ok(SAMPLE_COUNTRIES.includes(records[0].display_name));
+ });
+
+ QUnit.test("Sample data: any type", async function (assert) {
+ assert.expect(1);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const { records } = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'hobbit',
+ fields: ['display_name'],
+ });
+
+ assert.ok(SAMPLE_TEXTS.includes(records[0].display_name));
+ });
+
+ QUnit.module("RPC calls");
+
+ QUnit.test("Send 'search_read' RPC: valid field names", async function (assert) {
+ assert.expect(3);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'hobbit',
+ fields: ['display_name'],
+ });
+
+ assert.deepEqual(
+ Object.keys(result.records[0]),
+ ['id', 'display_name']
+ );
+ assert.strictEqual(result.length, SEARCH_READ_LIMIT);
+ assert.ok(/\w+/.test(result.records[0].display_name),
+ "Display name has been mocked"
+ );
+ });
+
+ QUnit.test("Send 'search_read' RPC: invalid field names", async function (assert) {
+ assert.expect(3);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'hobbit',
+ fields: ['name'],
+ });
+
+ assert.deepEqual(
+ Object.keys(result.records[0]),
+ ['id', 'name']
+ );
+ assert.strictEqual(result.length, SEARCH_READ_LIMIT);
+ assert.strictEqual(result.records[0].name, false,
+ `Field "name" doesn't exist => returns false`
+ );
+ });
+
+ QUnit.test("Send 'web_read_group' RPC: no group", async function (assert) {
+ assert.expect(1);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+ server.setExistingGroups([]);
+
+ const result = await server.mockRpc({
+ method: 'web_read_group',
+ model: 'hobbit',
+ groupBy: ['profession'],
+ });
+
+ assert.deepEqual(result, { groups: [], length: 0 });
+ });
+
+ QUnit.test("Send 'web_read_group' RPC: 2 groups", async function (assert) {
+ assert.expect(5);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+ const existingGroups = [
+ { profession: 'gardener', profession_count: 0 },
+ { profession: 'adventurer', profession_count: 0 },
+ ];
+ server.setExistingGroups(existingGroups);
+
+ const result = await server.mockRpc({
+ method: 'web_read_group',
+ model: 'hobbit',
+ groupBy: ['profession'],
+ fields: [],
+ });
+
+ assert.strictEqual(result.length, 2);
+ assert.strictEqual(result.groups.length, 2);
+
+ assert.deepEqual(
+ result.groups.map((g) => g.profession),
+ ["gardener", "adventurer"]
+ );
+
+ assert.strictEqual(
+ result.groups.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE
+ );
+ assert.ok(
+ result.groups.every((g) => g.profession_count === g.__data.length)
+ );
+ });
+
+ QUnit.test("Send 'web_read_group' RPC: all groups", async function (assert) {
+ assert.expect(5);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+ const existingGroups = [
+ { profession: 'gardener', profession_count: 0 },
+ { profession: 'brewer', profession_count: 0 },
+ { profession: 'adventurer', profession_count: 0 },
+ ];
+ server.setExistingGroups(existingGroups);
+
+ const result = await server.mockRpc({
+ method: 'web_read_group',
+ model: 'hobbit',
+ groupBy: ['profession'],
+ fields: [],
+ });
+
+ assert.strictEqual(result.length, 3);
+ assert.strictEqual(result.groups.length, 3);
+
+ assert.deepEqual(
+ result.groups.map((g) => g.profession),
+ ["gardener", "brewer", "adventurer"]
+ );
+
+ assert.strictEqual(
+ result.groups.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE
+ );
+ assert.ok(
+ result.groups.every((g) => g.profession_count === g.__data.length)
+ );
+ });
+
+ QUnit.test("Send 'read_group' RPC: no group", async function (assert) {
+ assert.expect(1);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: [],
+ });
+
+ assert.deepEqual(result, [{
+ __count: MAIN_RECORDSET_SIZE,
+ __domain: [],
+ }]);
+ });
+
+ QUnit.test("Send 'read_group' RPC: groupBy", async function (assert) {
+ assert.expect(3);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: ['profession'],
+ });
+
+ assert.strictEqual(result.length, 3);
+ assert.deepEqual(
+ result.map((g) => g.profession),
+ ["adventurer", "brewer", "gardener"]
+ );
+ assert.strictEqual(
+ result.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE,
+ );
+ });
+
+ QUnit.test("Send 'read_group' RPC: groupBy and field", async function (assert) {
+ assert.expect(4);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: ['age'],
+ groupBy: ['profession'],
+ });
+
+ assert.strictEqual(result.length, 3);
+ assert.deepEqual(
+ result.map((g) => g.profession),
+ ["adventurer", "brewer", "gardener"]
+ );
+ assert.strictEqual(
+ result.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE,
+ );
+ assert.strictEqual(
+ result.reduce((acc, g) => acc + g.age, 0),
+ server.data.hobbit.records.reduce((acc, g) => acc + g.age, 0)
+ );
+ });
+
+ QUnit.test("Send 'read_group' RPC: multiple groupBys and lazy", async function (assert) {
+ assert.expect(2);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: ['profession', 'age'],
+ });
+
+ assert.ok('profession' in result[0]);
+ assert.notOk('age' in result[0]);
+ });
+
+ QUnit.test("Send 'read_group' RPC: multiple groupBys and not lazy", async function (assert) {
+ assert.expect(2);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: ['profession', 'age'],
+ lazy: false,
+ });
+
+ assert.ok('profession' in result[0]);
+ assert.ok('age' in result[0]);
+ });
+
+ QUnit.test("Send 'read' RPC: no id", async function (assert) {
+ assert.expect(1);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read',
+ model: 'hobbit',
+ args: [
+ [], ['display_name']
+ ],
+ });
+
+ assert.deepEqual(result, []);
+ });
+
+ QUnit.test("Send 'read' RPC: one id", async function (assert) {
+ assert.expect(3);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read',
+ model: 'hobbit',
+ args: [
+ [1], ['display_name']
+ ],
+ });
+
+ assert.strictEqual(result.length, 1);
+ assert.ok(
+ /\w+/.test(result[0].display_name),
+ "Display name has been mocked"
+ );
+ assert.strictEqual(result[0].id, 1);
+ });
+
+ QUnit.test("Send 'read' RPC: more than all available ids", async function (assert) {
+ assert.expect(1);
+
+ const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
+
+ const amount = MAIN_RECORDSET_SIZE + 3;
+ const ids = new Array(amount).fill().map((_, i) => i + 1);
+ const result = await server.mockRpc({
+ method: 'read',
+ model: 'hobbit',
+ args: [
+ ids, ['display_name']
+ ],
+ });
+
+ assert.strictEqual(result.length, MAIN_RECORDSET_SIZE);
+ });
+
+ // To be implemented if needed
+ // QUnit.test("Send 'read_progress_bar' RPC", async function (assert) { ... });
+ });
+});
diff --git a/addons/web/static/tests/views/search_panel_tests.js b/addons/web/static/tests/views/search_panel_tests.js
new file mode 100644
index 00000000..cf2cd4b4
--- /dev/null
+++ b/addons/web/static/tests/views/search_panel_tests.js
@@ -0,0 +1,4173 @@
+odoo.define("web/static/tests/views/search_panel_tests.js", function (require) {
+"use strict";
+
+const FormView = require('web.FormView');
+const KanbanView = require('web.KanbanView');
+const ListView = require('web.ListView');
+const testUtils = require('web.test_utils');
+const SearchPanel = require("web/static/src/js/views/search_panel.js");
+
+const cpHelpers = testUtils.controlPanel;
+const createActionManager = testUtils.createActionManager;
+const createView = testUtils.createView;
+
+/**
+ * Return the list of counters displayed in the search panel (if any).
+ * @param {Widget} view, view controller
+ * @returns {number[]}
+ */
+function getCounters(view) {
+ return [...view.el.querySelectorAll('.o_search_panel_counter')].map(
+ counter => Number(counter.innerText.trim())
+ );
+}
+
+/**
+ * Fold/unfold the category value (with children)
+ * @param {Widget} widget
+ * @param {string} text
+ * @returns {Promise}
+ */
+function toggleFold(widget, text) {
+ const headers = [...widget.el.querySelectorAll(".o_search_panel_category_value header")];
+ const target = headers.find(
+ (header) => header.innerText.trim().startsWith(text)
+ );
+ return testUtils.dom.click(target);
+}
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ foo: {string: "Foo", type: 'char'},
+ bar: {string: "Bar", type: 'boolean'},
+ int_field: {string: "Int Field", type: 'integer', group_operator: 'sum'},
+ company_id: {string: "company", type: 'many2one', relation: 'company'},
+ company_ids: { string: "Companies", type: 'many2many', relation: 'company' },
+ category_id: { string: "category", type: 'many2one', relation: 'category' },
+ state: { string: "State", type: 'selection', selection: [['abc', "ABC"], ['def', "DEF"], ['ghi', "GHI"]]},
+ },
+ records: [
+ {id: 1, bar: true, foo: "yop", int_field: 1, company_ids: [3], company_id: 3, state: 'abc', category_id: 6},
+ {id: 2, bar: true, foo: "blip", int_field: 2, company_ids: [3], company_id: 5, state: 'def', category_id: 7},
+ {id: 3, bar: true, foo: "gnap", int_field: 4, company_ids: [], company_id: 3, state: 'ghi', category_id: 7},
+ {id: 4, bar: false, foo: "blip", int_field: 8, company_ids: [5], company_id: 5, state: 'ghi', category_id: 7},
+ ]
+ },
+ company: {
+ fields: {
+ name: {string: "Display Name", type: 'char'},
+ parent_id: {string: 'Parent company', type: 'many2one', relation: 'company'},
+ category_id: {string: 'Category', type: 'many2one', relation: 'category'},
+ },
+ records: [
+ {id: 3, name: "asustek", category_id: 6},
+ {id: 5, name: "agrolait", category_id: 7},
+ ],
+ },
+ category: {
+ fields: {
+ name: {string: "Category Name", type: 'char'},
+ },
+ records: [
+ {id: 6, name: "gold"},
+ {id: 7, name: "silver"},
+ ]
+ },
+ };
+
+ this.actions = [{
+ id: 1,
+ name: 'Partners',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'kanban'], [false, 'list'], [false, 'pivot'], [false, 'form']],
+ }, {
+ id: 2,
+ name: 'Partners',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'form']],
+ }];
+
+ this.archs = {
+ 'partner,false,list': '<tree><field name="foo"/></tree>',
+ 'partner,false,kanban':
+ `<kanban>
+ <templates>
+ <div t-name="kanban-box" class="oe_kanban_global_click">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ 'partner,false,form':
+ `<form>
+ <button name="1" type="action" string="multi view"/>
+ <field name="foo"/>
+ </form>`,
+ 'partner,false,pivot': '<pivot><field name="int_field" type="measure"/></pivot>',
+ 'partner,false,search':
+ `<search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1" expand="1"/>
+ <field name="category_id" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ };
+ },
+}, function () {
+
+ QUnit.module('SearchPanel');
+
+ QUnit.test('basic rendering', async function (assert) {
+ assert.expect(16);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method + ' on ' + args.model);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ <field name="category_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_content.o_controller_with_searchpanel > .o_search_panel');
+ assert.containsOnce(kanban, '.o_content.o_controller_with_searchpanel > .o_kanban_view');
+
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ assert.containsN(kanban, '.o_search_panel_section', 2);
+
+ var $firstSection = kanban.$('.o_search_panel_section:first');
+ assert.hasClass($firstSection.find('.o_search_panel_section_header i'), 'fa-folder');
+ assert.containsOnce($firstSection, '.o_search_panel_section_header:contains(company)');
+ assert.containsN($firstSection, '.o_search_panel_category_value', 3);
+ assert.containsOnce($firstSection, '.o_search_panel_category_value:first .active');
+ assert.strictEqual($firstSection.find('.o_search_panel_category_value').text().replace(/\s/g, ''),
+ 'Allasustek2agrolait2');
+
+ var $secondSection = kanban.$('.o_search_panel_section:nth(1)');
+ assert.hasClass($secondSection.find('.o_search_panel_section_header i'), 'fa-filter');
+ assert.containsOnce($secondSection, '.o_search_panel_section_header:contains(category)');
+ assert.containsN($secondSection, '.o_search_panel_filter_value', 2);
+ assert.strictEqual($secondSection.find('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'gold1silver3');
+
+ assert.verifySteps([
+ 'search_panel_select_range on partner',
+ 'search_panel_select_multi_range on partner',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('sections with custom icon and color', async function (assert) {
+ assert.expect(4);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" icon="fa-car" color="blue" enable_counters="1"/>
+ <field name="state" select="multi" icon="fa-star" color="#000" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.hasClass(kanban.$('.o_search_panel_section_header:first i'), 'fa-car');
+ assert.hasAttrValue(kanban.$('.o_search_panel_section_header:first i'), 'style="{color: blue}"');
+ assert.hasClass(kanban.$('.o_search_panel_section_header:nth(1) i'), 'fa-star');
+ assert.hasAttrValue(kanban.$('.o_search_panel_section_header:nth(1) i'), 'style="{color: #000}"');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('sections with attr invisible="1" are ignored', async function (assert) {
+ // 'groups' attributes are converted server-side into invisible="1" when the user doesn't
+ // belong to the given group
+ assert.expect(3);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `<kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ <field name="state" select="multi" invisible="1" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method || route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_search_panel_section');
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('categories and filters order is kept', async function (assert) {
+ assert.expect(4);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ <field name="category_id" select="multi" enable_counters="1"/>
+ <field name="state" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ }
+ });
+
+ assert.containsN(kanban, '.o_search_panel_section', 3);
+ assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(0)').text().trim(),
+ 'company');
+ assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(1)').text().trim(),
+ 'category');
+ assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(2)').text().trim(),
+ 'State');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('specify active category value in context and manually change category', async function (assert) {
+ assert.expect(5);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ <field name="state" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: {
+ searchpanel_default_company_id: false,
+ searchpanel_default_state: 'ghi',
+ },
+ });
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value header.active label')].map(
+ el => el.innerText
+ ),
+ ['All', 'GHI']
+ );
+
+ // select 'ABC' in the category 'state'
+ await testUtils.dom.click(kanban.el.querySelectorAll('.o_search_panel_category_value header')[4]);
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value header.active label')].map(
+ el => el.innerText
+ ),
+ ['All', 'ABC']
+ );
+
+ assert.verifySteps([
+ '[["state","=","ghi"]]',
+ '[["state","=","abc"]]'
+ ]);
+ kanban.destroy();
+ });
+
+ QUnit.test('use category (on many2one) to refine search', async function (assert) {
+ assert.expect(14);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ domain: [['bar', '=', true]],
+ });
+
+ // select 'asustek'
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // select 'agrolait'
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'All'
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ '[["bar","=",true]]',
+ '["&",["bar","=",true],["company_id","child_of",3]]',
+ '["&",["bar","=",true],["company_id","child_of",5]]',
+ '[["bar","=",true]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use category (on selection) to refine search', async function (assert) {
+ assert.expect(14);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ // select 'abc'
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'ghi'
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(3) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // select 'All' again
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ assert.verifySteps([
+ '[]',
+ '[["state","=","abc"]]',
+ '[["state","=","ghi"]]',
+ '[]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('category has been archived', async function (assert) {
+ assert.expect(2);
+
+ this.data.company.fields.active = {type: 'boolean', string: 'Archived'};
+ this.data.company.records = [
+ {
+ name: 'Company 5',
+ id: 5,
+ active: true,
+ }, {
+ name: 'child of 5 archived',
+ parent_id: 5,
+ id: 666,
+ active: false,
+ }, {
+ name: 'child of 666',
+ parent_id: 666,
+ id: 777,
+ active: true,
+ }
+ ];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ mockRPC: async function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/search_panel_select_range') {
+ var results = await this._super.apply(this, arguments);
+ results.values = results.values.filter(rec => rec.active !== false);
+ return Promise.resolve(results);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(kanban, '.o_search_panel_category_value', 2,
+ 'The number of categories should be 2: All and Company 5');
+
+ assert.containsNone(kanban, '.o_toggle_fold > i',
+ 'None of the categories should have children');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use two categories to refine search', async function (assert) {
+ assert.expect(14);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ <field name="state" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_section', 2);
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // select 'asustek'
+ await testUtils.dom.click(
+ [
+ ...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title')
+ ]
+ .filter(el => el.innerText === 'asustek')
+ );
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // select 'abc'
+ await testUtils.dom.click(
+ [
+ ...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title')
+ ]
+ .filter(el => el.innerText === 'ABC')
+ );
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'ghi'
+ await testUtils.dom.click(
+ [
+ ...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title')
+ ]
+ .filter(el => el.innerText === 'GHI')
+ );
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'All' in first category (company_id)
+ await testUtils.dom.click(kanban.$('.o_search_panel_section:first .o_search_panel_category_value:first header'));
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'All' in second category (state)
+ await testUtils.dom.click(kanban.$('.o_search_panel_section:nth(1) .o_search_panel_category_value:first header'));
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ '[["bar","=",true]]',
+ '["&",["bar","=",true],["company_id","child_of",3]]',
+ '["&",["bar","=",true],"&",["company_id","child_of",3],["state","=","abc"]]',
+ '["&",["bar","=",true],"&",["company_id","child_of",3],["state","=","ghi"]]',
+ '["&",["bar","=",true],["state","=","ghi"]]',
+ '[["bar","=",true]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('category with parent_field', async function (assert) {
+ assert.expect(33);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5});
+ this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5});
+ this.data.partner.records[1].company_id = 40;
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ // 'All' is selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+ assert.containsOnce(kanban, '.o_search_panel_category_value .o_toggle_fold > i');
+
+ // unfold parent category and select 'All' again
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header'));
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first > header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+ assert.containsN(kanban, '.o_search_panel_category_value .o_search_panel_category_value', 2);
+
+ // click on first child company
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value .o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value .o_search_panel_category_value:first .active');
+ assert.containsOnce(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ // click on parent company
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsOnce(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ // fold parent company by clicking on it
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // parent company should be folded
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // fold category with children
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header'));
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ assert.verifySteps([
+ '[]',
+ '[["company_id","child_of",5]]',
+ '[]',
+ '[["company_id","child_of",40]]',
+ '[["company_id","child_of",5]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('category with no parent_field', async function (assert) {
+ assert.expect(10);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="category_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ // 'All' is selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+
+ // click on 'gold' category
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsOnce(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ assert.verifySteps([
+ '[]',
+ '[["category_id","=",6]]', // must use '=' operator (instead of 'child_of')
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('can (un)fold parent category values', async function (assert) {
+ assert.expect(7);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5});
+ this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5});
+ this.data.partner.records[1].company_id = 40;
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i').length, 1,
+ "'agrolait' should be displayed as a parent category value");
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-right',
+ "'agrolait' should be folded");
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+
+ // unfold agrolait
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'));
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-down',
+ "'agrolait' should be open");
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+
+ // fold agrolait
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'));
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-right',
+ "'agrolait' should be folded");
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('fold status is kept at reload', async function (assert) {
+ assert.expect(4);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5});
+ this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5});
+ this.data.partner.records[1].company_id = 40;
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ // unfold agrolait
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) > header'));
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-down',
+ "'agrolait' should be open");
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+
+ await kanban.reload({});
+
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold > i'), 'fa-caret-down',
+ "'agrolait' should be open");
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('concurrency: delayed search_reads', async function (assert) {
+ assert.expect(19);
+
+ var def;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ return Promise.resolve(def).then(_.constant(result));
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ domain: [['bar', '=', true]],
+ });
+
+ // 'All' should be selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // select 'asustek' (delay the reload)
+ def = testUtils.makeTestPromise();
+ var asustekDef = def;
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ // 'asustek' should be selected, but there should still be 3 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // select 'agrolait' (delay the reload)
+ def = testUtils.makeTestPromise();
+ var agrolaitDef = def;
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
+
+ // 'agrolait' should be selected, but there should still be 3 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // unlock asustek search (should be ignored, so there should still be 3 records)
+ asustekDef.resolve();
+ await testUtils.nextTick();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // unlock agrolait search, there should now be 1 record
+ agrolaitDef.resolve();
+ await testUtils.nextTick();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ assert.verifySteps([
+ '[["bar","=",true]]',
+ '["&",["bar","=",true],["company_id","child_of",3]]',
+ '["&",["bar","=",true],["company_id","child_of",5]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("concurrency: single category", async function (assert) {
+ assert.expect(12);
+
+ let prom = testUtils.makeTestPromise();
+ const kanbanPromise = createView({
+ arch: `
+ <kanban>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ archs: {
+ "partner,false,search": `
+ <search>
+ <filter name="Filter" domain="[('id', '=', 1)]"/>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ context: {
+ searchpanel_default_company_id: [5],
+ },
+ data: this.data,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this);
+ if (route !== "/web/dataset/search_read") {
+ await prom;
+ }
+ assert.step(args.method || route);
+ return _super(...arguments);
+ },
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ // Case 1: search panel is awaited to build the query with search defaults
+ await testUtils.nextTick();
+ assert.verifySteps([]);
+
+ prom.resolve();
+ const kanban = await kanbanPromise;
+
+ assert.verifySteps([
+ "search_panel_select_range",
+ "/web/dataset/search_read",
+ ]);
+
+ // Case 2: search domain changed so we wait for the search panel once again
+ prom = testUtils.makeTestPromise();
+
+ await testUtils.controlPanel.toggleFilterMenu(kanban);
+ await testUtils.controlPanel.toggleMenuItem(kanban, 0);
+
+ assert.verifySteps([]);
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.verifySteps([
+ "search_panel_select_range",
+ "/web/dataset/search_read",
+ ]);
+
+ // Case 3: search domain is the same and default values do not matter anymore
+ prom = testUtils.makeTestPromise();
+
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ // The search read is executed right away in this case
+ assert.verifySteps(["/web/dataset/search_read"]);
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.verifySteps(["search_panel_select_range"]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("concurrency: category and filter", async function (assert) {
+ assert.expect(5);
+
+ let prom = testUtils.makeTestPromise();
+ const kanbanPromise = createView({
+ arch: `
+ <kanban>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ archs: {
+ "partner,false,search": `
+ <search>
+ <searchpanel>
+ <field name="category_id" enable_counters="1"/>
+ <field name="company_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this);
+ if (route !== "/web/dataset/search_read") {
+ await prom;
+ }
+ assert.step(args.method || route);
+ return _super(...arguments);
+ },
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ await testUtils.nextTick();
+ assert.verifySteps(["/web/dataset/search_read"]);
+
+ prom.resolve();
+ const kanban = await kanbanPromise;
+
+ assert.verifySteps([
+ "search_panel_select_range",
+ "search_panel_select_multi_range",
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("concurrency: category and filter with a domain", async function (assert) {
+ assert.expect(5);
+
+ let prom = testUtils.makeTestPromise();
+ const kanbanPromise = createView({
+ arch: `
+ <kanban>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ archs: {
+ "partner,false,search": `
+ <search>
+ <searchpanel>
+ <field name="category_id" enable_counters="1"/>
+ <field name="company_id" select="multi" domain="[['category_id', '=', category_id]]" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this);
+ if (route !== "/web/dataset/search_read") {
+ await prom;
+ }
+ assert.step(args.method || route);
+ return _super(...arguments);
+ },
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ await testUtils.nextTick();
+ assert.verifySteps([]);
+
+ prom.resolve();
+ const kanban = await kanbanPromise;
+
+ assert.verifySteps([
+ "search_panel_select_range",
+ "search_panel_select_multi_range",
+ "/web/dataset/search_read",
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('concurrency: misordered get_filters', async function (assert) {
+ assert.expect(15);
+
+ var def;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ return Promise.resolve(def).then(_.constant(result));
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" enable_counters="1"/>
+ <field name="company_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ // 'All' should be selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // select 'abc' (delay the reload)
+ def = testUtils.makeTestPromise();
+ var abcDef = def;
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ // 'All' should still be selected, and there should still be 4 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'ghi' (delay the reload)
+ def = testUtils.makeTestPromise();
+ var ghiDef = def;
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(3) header'));
+
+ // 'All' should still be selected, and there should still be 4 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // unlock ghi search
+ ghiDef.resolve();
+ await testUtils.nextTick();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // unlock abc search (should be ignored)
+ abcDef.resolve();
+ await testUtils.nextTick();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('concurrency: delayed get_filter', async function (assert) {
+ assert.expect(3);
+
+ var def;
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ return Promise.resolve(def).then(_.constant(result));
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="Filter" domain="[('id', '=', 1)]"/>
+ <searchpanel>
+ <field name="company_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // trigger a reload and delay the get_filter
+ def = testUtils.makeTestPromise();
+ await cpHelpers.toggleFilterMenu(kanban);
+ await cpHelpers.toggleMenuItem(kanban, 0);
+ await testUtils.nextTick();
+
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ def.resolve();
+ await testUtils.nextTick();
+
+ assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use filter (on many2one) to refine search', async function (assert) {
+ assert.expect(32);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['field_name', 'group_by', 'comodel_domain', 'search_domain', 'category_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: false,
+ comodel_domain: [],
+ search_domain: [['bar', '=', true]],
+ category_domain: [],
+ });
+ // the filter_domain depends on the filter selection
+ assert.step(JSON.stringify(args.kwargs.filter_domain));
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_value', 2);
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // check 'asustek'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // check 'agrolait'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // uncheck 'asustek'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // uncheck 'agrolait'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ // 'asustek' checked
+ '[]',
+ '["&",["bar","=",true],["company_id","in",[3]]]',
+ // 'asustek' and 'agrolait' checked
+ '[]',
+ '["&",["bar","=",true],["company_id","in",[3,5]]]',
+ // 'agrolait' checked
+ '[]',
+ '["&",["bar","=",true],["company_id","in",[5]]]',
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use filter (on selection) to refine search', async function (assert) {
+ assert.expect(32);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['group_by', 'comodel_domain', 'search_domain', 'category_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: false,
+ comodel_domain: [],
+ search_domain: [['bar', '=', true]],
+ category_domain: [],
+ });
+ // the filter_domain depends on the filter selection
+ assert.step(JSON.stringify(args.kwargs.filter_domain));
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_value', 3);
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // check 'abc'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI1');
+ assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // check 'def'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // uncheck 'abc'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI1');
+ assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)');
+
+ // uncheck 'def'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ // 'asustek' checked
+ '[]',
+ '["&",["bar","=",true],["state","in",["abc"]]]',
+ // 'asustek' and 'agrolait' checked
+ '[]',
+ '["&",["bar","=",true],["state","in",["abc","def"]]]',
+ // 'agrolait' checked
+ '[]',
+ '["&",["bar","=",true],["state","in",["def"]]]',
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("only reload categories and filters when domains change (counters disabled, selection)", async function (assert) {
+ assert.expect(8);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="Filter" domain="[('id', '&lt;', 5)]"/>
+ <searchpanel>
+ <field name="state" expand="1"/>
+ <field name="company_id" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'search_panel_select_multi_range',
+ ]);
+
+ // go to page 2 (the domain doesn't change, so the filters should not be reloaded)
+ await cpHelpers.pagerNext(kanban);
+
+ assert.verifySteps([]);
+
+ // reload with another domain, so the filters should be reloaded
+ await cpHelpers.toggleFilterMenu(kanban);
+ await cpHelpers.toggleMenuItem(kanban, 0);
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ ]);
+
+ // change category value, so the filters should be reloaded
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("only reload categories and filters when domains change (counters disabled, many2one)", async function (assert) {
+ assert.expect(8);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="domain" domain="[('id', '&lt;', 5)]"/>
+ <searchpanel>
+ <field name="category_id" expand="1"/>
+ <field name="company_id" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'search_panel_select_multi_range',
+ ]);
+
+ // go to page 2 (the domain doesn't change, so the filters should not be reloaded)
+ await cpHelpers.pagerNext(kanban);
+
+ assert.verifySteps([]);
+
+ // reload with another domain, so the filters should be reloaded
+ await cpHelpers.toggleFilterMenu(kanban);
+ await cpHelpers.toggleMenuItem(kanban, 0);
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ ]);
+
+ // change category value, so the filters should be reloaded
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('category counters', async function (assert) {
+ assert.expect(16);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ if (route === "/web/dataset/call_kw/partner/search_panel_select_range") {
+ assert.step(args.args[0]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="Filter" domain="[('id', '&lt;', 3)]"/>
+ <searchpanel>
+ <field name="state" enable_counters="1" expand="1"/>
+ <field name="company_id" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'state',
+ 'search_panel_select_range',
+ 'company_id',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI2", "All", "asustek", "agrolait"]
+ );
+
+ // go to page 2 (the domain doesn't change, so the categories should not be reloaded)
+ await cpHelpers.pagerNext(kanban);
+
+ assert.verifySteps([]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI2", "All", "asustek", "agrolait"]
+ );
+
+ // reload with another domain, so the categories 'state' and 'company_id' should be reloaded
+ await cpHelpers.toggleFilterMenu(kanban);
+ await cpHelpers.toggleMenuItem(kanban, 0);
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'state',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI", "All", "asustek", "agrolait"]
+ );
+
+ // change category value, so the category 'state' should be reloaded
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI", "All", "asustek", "agrolait"]
+ );
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'state',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('category selection without counters', async function (assert) {
+ assert.expect(10);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ if (route === "/web/dataset/call_kw/partner/search_panel_select_range") {
+ assert.step(args.args[0]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="Filter" domain="[('id', '&lt;', 3)]"/>
+ <searchpanel>
+ <field name="state" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'state',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ // go to page 2 (the domain doesn't change, so the categories should not be reloaded)
+ await cpHelpers.pagerNext(kanban);
+
+ assert.verifySteps([]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ // reload with another domain, so the category 'state' should be reloaded
+ await cpHelpers.toggleFilterMenu(kanban);
+ await cpHelpers.toggleMenuItem(kanban, 0);
+
+ assert.verifySteps([]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ // change category value, so the category 'state' should be reloaded
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ assert.verifySteps([]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('filter with groupby', async function (assert) {
+ assert.expect(42);
+
+ this.data.company.records.push({id: 11, name: 'camptocamp', category_id: 7});
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['group_by', 'comodel_domain', 'search_domain', 'category_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: 'category_id',
+ comodel_domain: [],
+ search_domain: [['bar', '=', true]],
+ category_domain: [],
+ });
+ // the filter_domain depends on the filter selection
+ assert.step(JSON.stringify(args.kwargs.filter_domain));
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" groupby="category_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_group', 2);
+ assert.containsOnce(kanban, '.o_search_panel_filter_group:first .o_search_panel_filter_value');
+ assert.containsN(kanban, '.o_search_panel_filter_group:nth(1) .o_search_panel_filter_value', 2);
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_group > header > div > label').text().replace(/\s/g, ''),
+ 'goldsilver');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1camptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // check 'asustek'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ var firstGroupCheckbox = kanban.$('.o_search_panel_filter_group:first > header > div > input').get(0);
+ assert.strictEqual(firstGroupCheckbox.checked, true,
+ "first group checkbox should be checked");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // check 'agrolait'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
+ var secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.checked, false,
+ "second group checkbox should not be checked");
+ assert.strictEqual(secondGroupCheckbox.indeterminate, true,
+ "second group checkbox should be indeterminate");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustekagrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 0);
+
+ // check 'camptocamp'
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(2) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 3);
+ secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.checked, true,
+ "second group checkbox should be checked");
+ assert.strictEqual(secondGroupCheckbox.indeterminate, false,
+ "second group checkbox should not be indeterminate");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustekagrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 0);
+
+ // uncheck second group
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.checked, false,
+ "second group checkbox should not be checked");
+ assert.strictEqual(secondGroupCheckbox.indeterminate, false,
+ "second group checkbox should not be indeterminate");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ assert.verifySteps([
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ // 'asustek' checked
+ '[]',
+ '["&",["bar","=",true],["company_id","in",[3]]]',
+ // 'asustek' and 'agrolait' checked
+ '[]',
+ '["&",["bar","=",true],"&",["company_id","in",[3]],["company_id","in",[5]]]',
+ // 'asustek', 'agrolait' and 'camptocamp' checked
+ '[]',
+ '["&",["bar","=",true],"&",["company_id","in",[3]],["company_id","in",[5,11]]]',
+ // 'asustek' checked
+ '[]',
+ '["&",["bar","=",true],["company_id","in",[3]]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('filter with domain', async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 3});
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ assert.deepEqual(args.kwargs, {
+ group_by: false,
+ category_domain: [],
+ expand: true,
+ filter_domain: [],
+ search_domain: [],
+ comodel_domain: [['parent_id', '=', false]],
+ group_domain: [],
+ enable_counters: true,
+ limit: 200,
+ });
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" domain="[('parent_id','=',False)]" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_value', 2);
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait2');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('filter with domain depending on category', async function (assert) {
+ assert.expect(22);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['group_by', 'search_domain', 'filter_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: false,
+ filter_domain: [],
+ search_domain: [],
+ });
+ assert.step(JSON.stringify(args.kwargs.category_domain));
+ assert.step(JSON.stringify(args.kwargs.comodel_domain));
+ }
+ return result;
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="category_id" enable_counters="1"/>
+ <field name="company_id" select="multi" domain="[['category_id', '=', category_id]]" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ // select 'gold' category
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsOnce(kanban, '.o_search_panel_filter_value');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ "asustek1");
+
+ // select 'silver' category
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsOnce(kanban, '.o_search_panel_filter_value');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ "agrolait2");
+
+ // select All
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsNone(kanban, '.o_search_panel_filter_value');
+
+ assert.verifySteps([
+ '[]', // category_domain (All)
+ '[["category_id","=",false]]', // comodel_domain (All)
+ '[["category_id","=",6]]', // category_domain ('gold')
+ '[["category_id","=",6]]', // comodel_domain ('gold')
+ '[["category_id","=",7]]', // category_domain ('silver')
+ '[["category_id","=",7]]', // comodel_domain ('silver')
+ '[]', // category_domain (All)
+ '[["category_id","=",false]]', // comodel_domain (All)
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('specify active filter values in context', async function (assert) {
+ assert.expect(4);
+
+ var expectedDomain = [
+ "&",
+ ['company_id', 'in', [5]],
+ ['state', 'in', ['abc', 'ghi']],
+ ];
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" enable_counters="1"/>
+ <field name="state" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, expectedDomain);
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: {
+ searchpanel_default_company_id: [5],
+ searchpanel_default_state: ['abc', 'ghi'],
+ },
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 3);
+
+ // manually untick a default value
+ expectedDomain = [['state', 'in', ['abc', 'ghi']]];
+ await testUtils.dom.click(kanban.$('.o_search_panel_filter:first .o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('retrieved filter value from context does not exist', async function (assert) {
+ assert.expect(1);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, [["company_id", "in", [3]]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: {
+ searchpanel_default_company_id: [1, 3],
+ },
+ });
+
+ kanban.destroy();
+ });
+
+ QUnit.test('filter with groupby and default values in context', async function (assert) {
+ assert.expect(2);
+
+ this.data.company.records.push({id: 11, name: 'camptocamp', category_id: 7});
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" groupby="category_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, [['company_id', 'in', [5]]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: {
+ searchpanel_default_company_id: [5],
+ },
+ });
+
+ var secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > header > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.indeterminate, true);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('Does not confuse false and "false" groupby values', async function (assert) {
+ assert.expect(6);
+
+ this.data.company.fields.char_field = {string: "Char Field", type: 'char'};
+
+ this.data.company.records = [
+ {id: 3, name: 'A', char_field: false, },
+ {id: 5, name: 'B', char_field: 'false', }
+ ];
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `<kanban>
+ <templates><t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t></templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" groupby="char_field"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_search_panel_section');
+ var $firstSection = kanban.$('.o_search_panel_section');
+
+ // There should be a group 'false' displayed with only value B inside it.
+ assert.containsOnce($firstSection, '.o_search_panel_filter_group');
+ assert.strictEqual($firstSection.find('.o_search_panel_filter_group').text().replace(/\s/g, ''),
+ 'falseB');
+ assert.containsOnce($firstSection.find('.o_search_panel_filter_group'), '.o_search_panel_filter_value');
+
+ // Globally, there should be two values, one displayed in the group 'false', and one at the end of the section
+ // (the group false is not displayed and its values are displayed at the first level)
+ assert.containsN($firstSection, '.o_search_panel_filter_value', 2);
+ assert.strictEqual($firstSection.find('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'BA');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('tests conservation of category record order', async function (assert) {
+ assert.expect(1);
+
+ this.data.company.records.push({id: 56, name: 'highID', category_id: 6});
+ this.data.company.records.push({id: 2, name: 'lowID', category_id: 6});
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1" expand="1"/>
+ <field name="category_id" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ var $firstSection = kanban.$('.o_search_panel_section:first');
+ assert.strictEqual($firstSection.find('.o_search_panel_category_value').text().replace(/\s/g, ''),
+ 'Allasustek2agrolait2highIDlowID');
+ kanban.destroy();
+ });
+
+ QUnit.test('search panel is available on list and kanban by default', async function (assert) {
+ assert.expect(8);
+
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ await actionManager.doAction(1);
+
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel');
+
+ await cpHelpers.switchView(actionManager, 'pivot');
+ await testUtils.nextTick();
+ assert.containsOnce(actionManager, '.o_content .o_pivot');
+ assert.containsNone(actionManager, '.o_content .o_search_panel');
+
+ await cpHelpers.switchView(actionManager, 'list');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_list_view');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel');
+
+ await testUtils.dom.click(actionManager.$('.o_data_row .o_data_cell:first'));
+ assert.containsOnce(actionManager, '.o_content .o_form_view');
+ assert.containsNone(actionManager, '.o_content .o_search_panel');
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('search panel with view_types attribute', async function (assert) {
+ assert.expect(6);
+
+ this.archs['partner,false,search'] =
+ `<search>
+ <searchpanel view_types="kanban,pivot">
+ <field name="company_id" enable_counters="1"/>
+ <field name="category_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`;
+
+
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ await actionManager.doAction(1);
+
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel');
+
+ await cpHelpers.switchView(actionManager, 'list');
+ assert.containsOnce(actionManager, '.o_content .o_list_view');
+ assert.containsNone(actionManager, '.o_content .o_search_panel');
+
+ await cpHelpers.switchView(actionManager, 'pivot');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_pivot');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel');
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('search panel state is shared between views', async function (assert) {
+ assert.expect(16);
+
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ await actionManager.doAction(1);
+
+ assert.hasClass(actionManager.$('.o_search_panel_category_value:first header'), 'active');
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // select 'asustek' company
+ await testUtils.dom.click(actionManager.$('.o_search_panel_category_value:nth(1) header'));
+ assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(1) header'), 'active');
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ await cpHelpers.switchView(actionManager, 'list');
+ assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(1) header'), 'active');
+ assert.containsN(actionManager, '.o_data_row', 2);
+
+ // select 'agrolait' company
+ await testUtils.dom.click(actionManager.$('.o_search_panel_category_value:nth(2) header'));
+ assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(2) header'), 'active');
+ assert.containsN(actionManager, '.o_data_row', 2);
+
+ await cpHelpers.switchView(actionManager, 'kanban');
+ assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(2) header'), 'active');
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ assert.verifySteps([
+ '[]', // initial search_read
+ '[["company_id","child_of",3]]', // kanban, after selecting the first company
+ '[["company_id","child_of",3]]', // list
+ '[["company_id","child_of",5]]', // list, after selecting the other company
+ '[["company_id","child_of",5]]', // kanban
+ ]);
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('search panel filters are kept between switch views', async function (assert) {
+ assert.expect(17);
+
+ const actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ await actionManager.doAction(1);
+
+ assert.containsNone(actionManager, '.o_search_panel_filter_value input:checked');
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // select gold filter
+ await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(0)'));
+ assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked');
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ await cpHelpers.switchView(actionManager, 'list');
+ assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked');
+ assert.containsN(actionManager, '.o_data_row', 1);
+
+ // select silver filter
+ await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(1)'));
+ assert.containsN(actionManager, '.o_search_panel_filter_value input:checked', 2);
+ assert.containsN(actionManager, '.o_data_row', 4);
+
+ await cpHelpers.switchView(actionManager, 'kanban');
+ assert.containsN(actionManager, '.o_search_panel_filter_value input:checked', 2);
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ await testUtils.dom.click(actionManager.$(".o_kanban_record:nth(0)"));
+ await testUtils.dom.click(actionManager.$(".breadcrumb-item:nth(0)"));
+
+ assert.verifySteps([
+ '[]', // initial search_read
+ '[["category_id","in",[6]]]', // kanban, after selecting the gold filter
+ '[["category_id","in",[6]]]', // list
+ '[["category_id","in",[6,7]]]', // list, after selecting the silver filter
+ '[["category_id","in",[6,7]]]', // kanban
+ '[["category_id","in",[6,7]]]', // kanban, after switching back from form view
+ ]);
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('search panel filters are kept when switching to a view with no search panel', async function (assert) {
+ assert.expect(13);
+
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ await actionManager.doAction(1);
+
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel');
+ assert.containsNone(actionManager, '.o_search_panel_filter_value input:checked');
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // select gold filter
+ await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(0)'));
+ assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked');
+ assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // switch to pivot
+ await cpHelpers.switchView(actionManager, 'pivot');
+ assert.containsOnce(actionManager, '.o_content .o_pivot');
+ assert.containsNone(actionManager, '.o_content .o_search_panel');
+ assert.strictEqual(actionManager.$('.o_pivot_cell_value').text(), '15');
+
+ // switch to list
+ await cpHelpers.switchView(actionManager, 'list');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_list_view');
+ assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel');
+ assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked');
+ assert.containsN(actionManager, '.o_data_row', 1);
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('after onExecuteAction, selects "All" as default category value', async function (assert) {
+ assert.expect(4);
+
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ await actionManager.doAction(2);
+ await testUtils.dom.click(actionManager.$('.o_form_view button:contains("multi view")'));
+
+ assert.containsOnce(actionManager, '.o_kanban_view');
+ assert.containsOnce(actionManager, '.o_search_panel');
+ assert.containsOnce(actionManager, '.o_search_panel_category_value:first .active');
+
+ assert.verifySteps([]); // should not communicate with localStorage
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('search panel is not instantiated if stated in context', async function (assert) {
+ assert.expect(2);
+
+ this.actions[0].context = {search_panel: false};
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ await actionManager.doAction(2);
+ await testUtils.dom.click(actionManager.$('.o_form_view button:contains("multi view")'));
+
+ assert.containsOnce(actionManager, '.o_kanban_view');
+ assert.containsNone(actionManager, '.o_search_panel');
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('categories and filters are not reloaded when switching between views', async function (assert) {
+ assert.expect(3);
+
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ await actionManager.doAction(1);
+
+ await cpHelpers.switchView(actionManager, 'list');
+ await cpHelpers.switchView(actionManager, 'kanban');
+
+ assert.verifySteps([
+ 'search_panel_select_range', // kanban: categories
+ 'search_panel_select_multi_range', // kanban: filters
+ ]);
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('scroll position is kept when switching between controllers', async function (assert) {
+ assert.expect(6);
+
+ const originalDebounce = SearchPanel.scrollDebounce;
+ SearchPanel.scrollDebounce = 0;
+
+ for (var i = 10; i < 20; i++) {
+ this.data.category.records.push({id: i, name: "Cat " + i});
+ }
+
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ actionManager.$el.css('max-height', 300);
+
+ async function scroll(top) {
+ actionManager.el.querySelector(".o_search_panel").scrollTop = top;
+ await testUtils.nextTick();
+ }
+
+ await actionManager.doAction(1);
+
+ assert.containsOnce(actionManager, '.o_content .o_kanban_view');
+ assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 0);
+
+ // simulate a scroll in the search panel and switch into list
+ await scroll(50);
+ await cpHelpers.switchView(actionManager, 'list');
+ assert.containsOnce(actionManager, '.o_content .o_list_view');
+ assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 50);
+
+ // simulate another scroll and switch back to kanban
+ await scroll(30);
+ await cpHelpers.switchView(actionManager, 'kanban');
+ assert.containsOnce(actionManager, '.o_content .o_kanban_view');
+ assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 30);
+
+ actionManager.destroy();
+ SearchPanel.scrollDebounce = originalDebounce;
+ });
+
+ QUnit.test('search panel is not instantiated in dialogs', async function (assert) {
+ assert.expect(2);
+
+ this.data.company.records = [
+ {id: 1, name: 'Company1'},
+ {id: 2, name: 'Company2'},
+ {id: 3, name: 'Company3'},
+ {id: 4, name: 'Company4'},
+ {id: 5, name: 'Company5'},
+ {id: 6, name: 'Company6'},
+ {id: 7, name: 'Company7'},
+ {id: 8, name: 'Company8'},
+ ];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="company_id"/></form>',
+ archs: {
+ 'company,false,list': '<tree><field name="name"/></tree>',
+ 'company,false,search':
+ `<search>
+ <field name="name"/>
+ <searchpanel>
+ <field name="category_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ await testUtils.fields.many2one.clickOpenDropdown('company_id');
+ await testUtils.fields.many2one.clickItem('company_id', 'Search More');
+
+ assert.containsOnce(document.body, '.modal .o_list_view');
+ assert.containsNone(document.body, '.modal .o_search_panel');
+
+ form.destroy();
+ });
+
+
+ QUnit.test("Reload categories with counters when filter values are selected", async function (assert) {
+ assert.expect(8);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="category_id" enable_counters="1"/>
+ <field name="state" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ "search_panel_select_multi_range",
+ ]);
+
+ assert.deepEqual(getCounters(kanban), [
+ 1, 3, // category counts (in order)
+ 1, 1, 2 // filter counts
+ ]);
+
+ await testUtils.dom.click(kanban.el.querySelector('.o_search_panel_filter_value input'));
+
+ assert.deepEqual(getCounters(kanban), [
+ 1, // category counts (for silver: 0 is not displayed)
+ 1, 1, 2 // filter counts
+ ]);
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ "search_panel_select_multi_range",
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, expand, hierarchize, counters", async function (assert) {
+ assert.expect(5);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3);
+ assert.containsOnce(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2 ,1]);
+
+ await toggleFold(kanban, "agrolait");
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5);
+ assert.deepEqual(getCounters(kanban), [2, 1, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, no expand, hierarchize, counters", async function (assert) {
+ assert.expect(5);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3);
+ assert.containsOnce(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 1]);
+
+ await toggleFold(kanban, "agrolait");
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4);
+ assert.deepEqual(getCounters(kanban), [2, 1, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, expand, no hierarchize, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" hierarchize="0" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 1, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, no expand, no hierarchize, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" hierarchize="0" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 1, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, expand, hierarchize, no counters", async function (assert) {
+ assert.expect(5);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3);
+ assert.containsOnce(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ await toggleFold(kanban, "agrolait");
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5);
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, no expand, hierarchize, no counters", async function (assert) {
+ assert.expect(5);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3);
+ assert.containsOnce(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ await toggleFold(kanban, "agrolait");
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4);
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, expand, no hierarchize, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" hierarchize="0" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 5);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select one, no expand, no hierarchize, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 50, name: 'agrobeurre', parent_id: 5 });
+ this.data.company.records.push({ id: 51, name: 'agrocrèmefraiche', parent_id: 5 });
+ this.data.partner.records[1].company_id = 50;
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" hierarchize="0"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, expand, groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" groupby="category_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 5);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, no expand, groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" groupby="category_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, expand, no groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, no expand, no groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 2);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, expand, groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" groupby="category_id" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 5);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, no expand, groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" groupby="category_id"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, expand, no groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2one: select multi, no expand, no groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 2);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, expand, groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi" groupby="category_id" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 5);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, no expand, groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi" groupby="category_id" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, expand, no groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, no expand, no groupby, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 2);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [2, 1]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, expand, groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi" groupby="category_id" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 5);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, no expand, groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi" groupby="category_id"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, expand, no groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("many2many: select multi, no expand, no groupby, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({ id: 666, name: "Mordor Inc.", category_id: 6 });
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_ids" select="multi"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 2);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select one, expand, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [1, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select one, no expand, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [1, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select one, expand, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 4);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select one, no expand, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_field .o_search_panel_category_value', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select multi, expand, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" select="multi" enable_counters="1" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [1, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select multi, no expand, counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 2);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [1, 2]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select multi, expand, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" select="multi" expand="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 3);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("selection: select multi, no expand, no counters", async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" select="multi"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 2);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), []);
+
+ kanban.destroy();
+ });
+
+ //-------------------------------------------------------------------------
+ // Model domain and count domain distinction
+ //-------------------------------------------------------------------------
+
+ QUnit.test("selection: select multi, no expand, counters, extra_domain", async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records.shift();
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id"/>
+ <field name="state" select="multi" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsN(kanban, '.o_search_panel_label', 5);
+ assert.containsNone(kanban, '.o_toggle_fold > i');
+ assert.deepEqual(getCounters(kanban), [1, 2]);
+
+ await toggleFold(kanban, "asustek");
+
+ assert.containsN(kanban, '.o_search_panel_label', 5);
+ assert.deepEqual(getCounters(kanban), [1]);
+
+ kanban.destroy();
+ });
+
+ //-------------------------------------------------------------------------
+ // Limit
+ //-------------------------------------------------------------------------
+
+ QUnit.test("reached limit for a category", async function (assert) {
+ assert.expect(6);
+
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" limit="2"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsOnce(kanban, '.o_search_panel_section');
+ assert.containsOnce(kanban, '.o_search_panel_section_header');
+ assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, "COMPANY");
+ assert.containsOnce(kanban, 'section div.alert.alert-warning');
+ assert.strictEqual(kanban.el.querySelector('section div.alert.alert-warning').innerText, "Too many items to display.");
+ assert.containsNone(kanban, '.o_search_panel_category_value');
+
+ kanban.destroy();
+ });
+
+ QUnit.test("reached limit for a filter", async function (assert) {
+ assert.expect(6);
+
+ const kanban = await createView({
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="company_id" select="multi" limit="2"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: 'partner',
+ View: KanbanView,
+ });
+
+ assert.containsOnce(kanban, '.o_search_panel_section');
+ assert.containsOnce(kanban, '.o_search_panel_section_header');
+ assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, "COMPANY");
+ assert.containsOnce(kanban, 'section div.alert.alert-warning');
+ assert.strictEqual(kanban.el.querySelector('section div.alert.alert-warning').innerText, "Too many items to display.");
+ assert.containsNone(kanban, '.o_search_panel_filter_value');
+
+ kanban.destroy();
+ });
+
+ QUnit.test("a selected value becomming invalid should no more impact the view", async function (assert) {
+ assert.expect(13);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="filter_on_def" string="DEF" domain="[('state', '=', 'def')]"/>
+ <searchpanel>
+ <field name="state" enable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ ]);
+
+ assert.containsN(kanban, '.o_kanban_record span', 4);
+
+ // select 'ABC' in search panel
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ ]);
+
+ assert.containsOnce(kanban, '.o_kanban_record span');
+ assert.strictEqual(kanban.el.querySelector('.o_kanban_record span').innerText, 'yop' );
+
+ // select DEF in filter menu
+ await testUtils.controlPanel.toggleFilterMenu(kanban);
+ await testUtils.controlPanel.toggleMenuItem(kanban, 'DEF');
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ ]);
+
+ const firstCategoryValue = kanban.el.querySelector('.o_search_panel_category_value header');
+ assert.strictEqual(firstCategoryValue.innerText, 'All');
+ assert.hasClass(
+ firstCategoryValue, 'active',
+ "the value 'All' should be selected since ABC is no longer a valid value with respect to search domain"
+ );
+ assert.containsOnce(kanban, '.o_kanban_record span');
+ assert.strictEqual(kanban.el.querySelector('.o_kanban_record span').innerText, 'blip' );
+
+ kanban.destroy();
+ });
+
+ QUnit.test("Categories with default attributes should be udpated when external domain changes", async function (assert) {
+ assert.expect(8);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method && args.method.includes('search_panel_')) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <filter name="filter_on_def" string="DEF" domain="[('state', '=', 'def')]"/>
+ <searchpanel>
+ <field name="state"/>
+ </searchpanel>
+ </search>`,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ ]);
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value header label')].map(el => el.innerText),
+ ['All', 'ABC', 'DEF', 'GHI']
+ );
+
+ // select 'ABC' in search panel --> no need to update the category value
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.verifySteps([]);
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value header label')].map(el => el.innerText),
+ ['All', 'ABC', 'DEF', 'GHI']
+ );
+
+ // select DEF in filter menu --> the external domain changes --> the values should be updated
+ await testUtils.controlPanel.toggleFilterMenu(kanban);
+ await testUtils.controlPanel.toggleMenuItem(kanban, 'DEF');
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ ]);
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value header label')].map(el => el.innerText),
+ ['All', 'DEF']
+ );
+
+ kanban.destroy();
+ });
+
+ QUnit.test("Category with counters and filter with domain", async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: '<tree><field name="foo"/></tree>',
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="category_id" enable_counters="1"/>
+ <field name="company_id" select="multi" domain="[['category_id', '=', category_id]]"/>
+ </searchpanel>
+ </search>`,
+ },
+ data: this.data,
+ model: "partner",
+ services: this.services,
+ View: ListView,
+ });
+
+ assert.containsN(list, ".o_data_row", 4);
+ assert.strictEqual(
+ list.$(".o_search_panel_category_value").text().replace(/\s/g, ""),
+ "Allgoldsilver",
+ "Category counters should be empty if a filter has a domain attribute"
+ );
+
+ list.destroy();
+ });
+});
+});
diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js
new file mode 100644
index 00000000..046fb873
--- /dev/null
+++ b/addons/web/static/tests/views/view_dialogs_tests.js
@@ -0,0 +1,615 @@
+odoo.define('web.view_dialogs_tests', function (require) {
+"use strict";
+
+var dialogs = require('web.view_dialogs');
+var ListController = require('web.ListController');
+var testUtils = require('web.test_utils');
+var Widget = require('web.Widget');
+var FormView = require('web.FormView');
+
+const cpHelpers = testUtils.controlPanel;
+var createView = testUtils.createView;
+
+async function createParent(params) {
+ var widget = new Widget();
+ params.server = await testUtils.mock.addMockEnvironment(widget, params);
+ return widget;
+}
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ foo: {string: "Foo", type: 'char'},
+ bar: {string: "Bar", type: "boolean"},
+ instrument: {string: 'Instruments', type: 'many2one', relation: 'instrument'},
+ },
+ records: [
+ {id: 1, foo: 'blip', display_name: 'blipblip', bar: true},
+ {id: 2, foo: 'ta tata ta ta', display_name: 'macgyver', bar: false},
+ {id: 3, foo: 'piou piou', display_name: "Jack O'Neill", bar: true},
+ ],
+ },
+ instrument: {
+ fields: {
+ name: {string: "name", type: "char"},
+ badassery: {string: 'level', type: 'many2many', relation: 'badassery', domain: [['level', '=', 'Awsome']]},
+ },
+ },
+
+ badassery: {
+ fields: {
+ level: {string: 'level', type: "char"},
+ },
+ records: [
+ {id: 1, level: 'Awsome'},
+ ],
+ },
+
+ product: {
+ fields : {
+ name: {string: "name", type: "char" },
+ partner : {string: 'Doors', type: 'one2many', relation: 'partner'},
+ },
+ records: [
+ {id: 1, name: 'The end'},
+ ],
+ },
+ };
+ },
+}, function () {
+
+ QUnit.module('view_dialogs');
+
+ QUnit.test('formviewdialog buttons in footer are positioned properly', async function (assert) {
+ assert.expect(2);
+
+ var parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner">' +
+ '<sheet>' +
+ '<group><field name="foo"/></group>' +
+ '<footer><button string="Custom Button" type="object" class="btn-primary"/></footer>' +
+ '</sheet>' +
+ '</form>',
+ },
+ });
+
+ new dialogs.FormViewDialog(parent, {
+ res_model: 'partner',
+ res_id: 1,
+ }).open();
+ await testUtils.nextTick();
+
+ assert.notOk($('.modal-body button').length,
+ "should not have any button in body");
+ assert.strictEqual($('.modal-footer button').length, 1,
+ "should have only one button in footer");
+ parent.destroy();
+ });
+
+ QUnit.test('formviewdialog buttons in footer are not duplicated', async function (assert) {
+ assert.expect(2);
+ this.data.partner.fields.poney_ids = {string: "Poneys", type: "one2many", relation: 'partner'};
+ this.data.partner.records[0].poney_ids = [];
+
+ var parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner">' +
+ '<field name="poney_ids"><tree editable="top"><field name="display_name"/></tree></field>' +
+ '<footer><button string="Custom Button" type="object" class="btn-primary"/></footer>' +
+ '</form>',
+ },
+ });
+
+ new dialogs.FormViewDialog(parent, {
+ res_model: 'partner',
+ res_id: 1,
+ }).open();
+ await testUtils.nextTick();
+
+ assert.strictEqual($('.modal button.btn-primary').length, 1,
+ "should have 1 buttons in modal");
+
+ await testUtils.dom.click($('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.triggerKeydown($('input.o_input'), 'escape');
+
+ assert.strictEqual($('.modal button.btn-primary').length, 1,
+ "should still have 1 buttons in modal");
+ parent.destroy();
+ });
+
+ QUnit.test('SelectCreateDialog use domain, group_by and search default', async function (assert) {
+ assert.expect(3);
+
+ var search = 0;
+ var parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,list':
+ '<tree string="Partner">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '</tree>',
+ 'partner,false,search':
+ '<search>' +
+ '<field name="foo" filter_domain="[(\'display_name\',\'ilike\',self), (\'foo\',\'ilike\',self)]"/>' +
+ '<group expand="0" string="Group By">' +
+ '<filter name="groupby_bar" context="{\'group_by\' : \'bar\'}"/>' +
+ '</group>' +
+ '</search>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.deepEqual(args.kwargs, {
+ context: {
+ search_default_foo: "piou",
+ search_default_groupby_bar: true,
+ },
+ domain: ["&", ["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
+ fields: ["display_name", "foo", "bar"],
+ groupby: ["bar"],
+ orderby: '',
+ lazy: true,
+ limit: 80,
+ }, "should search with the complete domain (domain + search), and group by 'bar'");
+ }
+ if (search === 0 && route === '/web/dataset/search_read') {
+ search++;
+ assert.deepEqual(args, {
+ context: {
+ search_default_foo: "piou",
+ search_default_groupby_bar: true,
+ bin_size: true
+ }, // not part of the test, may change
+ domain: ["&", ["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
+ fields: ["display_name", "foo"],
+ model: "partner",
+ limit: 80,
+ sort: ""
+ }, "should search with the complete domain (domain + search)");
+ } else if (search === 1 && route === '/web/dataset/search_read') {
+ assert.deepEqual(args, {
+ context: {
+ search_default_foo: "piou",
+ search_default_groupby_bar: true,
+ bin_size: true
+ }, // not part of the test, may change
+ domain: [["display_name", "like", "a"]],
+ fields: ["display_name", "foo"],
+ model: "partner",
+ limit: 80,
+ sort: ""
+ }, "should search with the domain");
+ }
+
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var dialog;
+ new dialogs.SelectCreateDialog(parent, {
+ no_create: true,
+ readonly: true,
+ res_model: 'partner',
+ domain: [['display_name', 'like', 'a']],
+ context: {
+ search_default_groupby_bar: true,
+ search_default_foo: 'piou',
+ },
+ }).open().then(function (result) {
+ dialog = result;
+ });
+ await testUtils.nextTick();
+ await cpHelpers.removeFacet('.modal', "Bar");
+ await cpHelpers.removeFacet('.modal');
+
+ parent.destroy();
+ });
+
+ QUnit.test('SelectCreateDialog correctly evaluates domains', async function (assert) {
+ assert.expect(1);
+
+ var parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,list':
+ '<tree string="Partner">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '</tree>',
+ 'partner,false,search':
+ '<search>' +
+ '<field name="foo"/>' +
+ '</search>',
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, [['id', '=', 2]],
+ "should have correctly evaluated the domain");
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_context: {uid: 2},
+ },
+ });
+
+ new dialogs.SelectCreateDialog(parent, {
+ no_create: true,
+ readonly: true,
+ res_model: 'partner',
+ domain: "[['id', '=', uid]]",
+ }).open();
+ await testUtils.nextTick();
+
+ parent.destroy();
+ });
+
+ QUnit.test('SelectCreateDialog list view in readonly', async function (assert) {
+ assert.expect(1);
+
+ var parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,list':
+ '<tree string="Partner" editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '</tree>',
+ 'partner,false,search':
+ '<search/>'
+ },
+ });
+
+ var dialog;
+ new dialogs.SelectCreateDialog(parent, {
+ res_model: 'partner',
+ }).open().then(function (result) {
+ dialog = result;
+ });
+ await testUtils.nextTick();
+
+ // click on the first row to see if the list is editable
+ await testUtils.dom.click(dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first'));
+
+ assert.equal(dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first input').length, 0,
+ "list view should not be editable in a SelectCreateDialog");
+
+ parent.destroy();
+ });
+
+ QUnit.test('SelectCreateDialog cascade x2many in create mode', async function (assert) {
+ assert.expect(5);
+
+ var form = await createView({
+ View: FormView,
+ model: 'product',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="name"/>' +
+ '<field name="partner" widget="one2many" >' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<field name="instrument"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<field name="name"/>' +
+ '<field name="instrument" widget="one2many" mode="tree"/>' +
+ '</form>',
+
+ 'instrument,false,form': '<form>'+
+ '<field name="name"/>'+
+ '<field name="badassery">' +
+ '<tree>'+
+ '<field name="level"/>'+
+ '</tree>' +
+ '</field>' +
+ '</form>',
+
+ 'badassery,false,list': '<tree>'+
+ '<field name="level"/>'+
+ '</tree>',
+
+ 'badassery,false,search': '<search>'+
+ '<field name="level"/>'+
+ '</search>',
+ },
+
+ mockRPC: function(route, args) {
+ if (route === '/web/dataset/call_kw/partner/get_formview_id') {
+ return Promise.resolve(false);
+ }
+ if (route === '/web/dataset/call_kw/instrument/get_formview_id') {
+ return Promise.resolve(false);
+ }
+ if (route === '/web/dataset/call_kw/instrument/create') {
+ assert.deepEqual(args.args, [{badassery: [[6, false, [1]]], name: "ABC"}],
+ 'The method create should have been called with the right arguments');
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.many2one.createAndEdit("instrument");
+
+ var $modal = $('.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ await testUtils.dom.click($modal.find('.o_field_x2many_list_row_add a'));
+
+ var $modals = $('.modal-lg');
+
+ assert.equal($modals.length, 2,
+ 'There should be two modals');
+
+ var $second_modal = $modals.not($modal);
+ await testUtils.dom.click($second_modal.find('.o_list_table.table.table-sm.table-striped.o_list_table_ungrouped .o_data_row input[type=checkbox]'));
+
+ await testUtils.dom.click($second_modal.find('.o_select_button'));
+
+ $modal = $('.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ assert.equal($modal.find('.o_data_cell').text(), 'Awsome',
+ 'There should be one item in the list of the modal');
+
+ await testUtils.dom.click($modal.find('.btn.btn-primary'));
+
+ form.destroy();
+ });
+
+ QUnit.test('Form dialog and subview with _view_ref contexts', async function (assert) {
+ assert.expect(2);
+
+ this.data.instrument.records = [{id: 1, name: 'Tromblon', badassery: [1]}];
+ this.data.partner.records[0].instrument = 1;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="name"/>' +
+ '<field name="instrument" context="{\'tree_view_ref\': \'some_tree_view\'}"/>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'instrument,false,form': '<form>'+
+ '<field name="name"/>'+
+ '<field name="badassery" context="{\'tree_view_ref\': \'some_other_tree_view\'}"/>' +
+ '</form>',
+
+ 'badassery,false,list': '<tree>'+
+ '<field name="level"/>'+
+ '</tree>',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+
+ mockRPC: function(route, args) {
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+
+ interceptsPropagate: {
+ load_views: function (ev) {
+ var evaluatedContext = ev.data.context;
+ if (ev.data.modelName === 'instrument') {
+ assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_tree_view'},
+ 'The correct _view_ref should have been sent to the server, first time');
+ }
+ if (ev.data.modelName === 'badassery') {
+ assert.deepEqual(evaluatedContext, {
+ base_model_name: 'instrument',
+ tree_view_ref: 'some_other_tree_view',
+ }, 'The correct _view_ref should have been sent to the server for the subview');
+ }
+ },
+ },
+ });
+
+ await testUtils.dom.click(form.$('.o_field_widget[name="instrument"] button.o_external_button'));
+ form.destroy();
+ });
+
+ QUnit.test('SelectCreateDialog: save current search', async function (assert) {
+ assert.expect(4);
+
+ testUtils.mock.patch(ListController, {
+ getOwnedQueryParams: function () {
+ return {
+ context: {
+ shouldBeInFilterContext: true,
+ },
+ };
+ },
+ });
+
+ var parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,list':
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ 'partner,false,search':
+ '<search>' +
+ '<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', True)]"/>' +
+ '</search>',
+
+ },
+ env: {
+ dataManager: {
+ create_filter: function (filter) {
+ assert.strictEqual(filter.domain, `[("bar", "=", True)]`,
+ "should save the correct domain");
+ const expectedContext = {
+ group_by: [], // default groupby is an empty list
+ shouldBeInFilterContext: true,
+ };
+ assert.deepEqual(filter.context, expectedContext,
+ "should save the correct context");
+ },
+ }
+ },
+ });
+
+ var dialog;
+ new dialogs.SelectCreateDialog(parent, {
+ context: {shouldNotBeInFilterContext: false},
+ res_model: 'partner',
+ }).open().then(function (result) {
+ dialog = result;
+ });
+ await testUtils.nextTick();
+
+
+ assert.containsN(dialog, '.o_data_row', 3, "should contain 3 records");
+
+ // filter on bar
+ await cpHelpers.toggleFilterMenu('.modal');
+ await cpHelpers.toggleMenuItem('.modal', "Bar");
+
+ assert.containsN(dialog, '.o_data_row', 2, "should contain 2 records");
+
+ // save filter
+ await cpHelpers.toggleFavoriteMenu('.modal');
+ await cpHelpers.toggleSaveFavorite('.modal');
+ await cpHelpers.editFavoriteName('.modal', "some name");
+ await cpHelpers.saveFavorite('.modal');
+
+ testUtils.mock.unpatch(ListController);
+ parent.destroy();
+ });
+
+ QUnit.test('propagate can_create onto the search popup o2m', async function (assert) {
+ assert.expect(4);
+
+ this.data.instrument.records = [
+ {id: 1, name: 'Tromblon1'},
+ {id: 2, name: 'Tromblon2'},
+ {id: 3, name: 'Tromblon3'},
+ {id: 4, name: 'Tromblon4'},
+ {id: 5, name: 'Tromblon5'},
+ {id: 6, name: 'Tromblon6'},
+ {id: 7, name: 'Tromblon7'},
+ {id: 8, name: 'Tromblon8'},
+ ];
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="name"/>' +
+ '<field name="instrument" can_create="false"/>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'instrument,false,list': '<tree>'+
+ '<field name="name"/>'+
+ '</tree>',
+ 'instrument,false,search': '<search>'+
+ '<field name="name"/>'+
+ '</search>',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+
+ mockRPC: function(route, args) {
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ await testUtils.fields.many2one.clickOpenDropdown('instrument');
+
+ assert.containsNone(form, '.ui-autocomplete a:contains(Start typing...)');
+
+ await testUtils.fields.editInput(form.el.querySelector(".o_field_many2one[name=instrument] input"), "a");
+
+ assert.containsNone(form, '.ui-autocomplete a:contains(Create and Edit)');
+
+ await testUtils.fields.editInput(form.el.querySelector(".o_field_many2one[name=instrument] input"), "");
+ await testUtils.fields.many2one.clickItem('instrument', 'Search More...');
+
+ var $modal = $('.modal-dialog.modal-lg');
+
+ assert.strictEqual($modal.length, 1, 'Modal present');
+
+ assert.strictEqual($modal.find('.modal-footer button').text(), "Cancel",
+ 'Only the cancel button is present in modal');
+
+ form.destroy();
+ });
+
+ QUnit.test('formviewdialog is not closed when button handlers return a rejected promise', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.poney_ids = { string: "Poneys", type: "one2many", relation: 'partner' };
+ this.data.partner.records[0].poney_ids = [];
+ var reject = true;
+
+ var parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner">' +
+ '<field name="poney_ids"><tree><field name="display_name"/></tree></field>' +
+ '</form>',
+ },
+ });
+
+ new dialogs.FormViewDialog(parent, {
+ res_model: 'partner',
+ res_id: 1,
+ buttons: [{
+ text: 'Click me !',
+ classes: "btn-secondary o_form_button_magic",
+ close: true,
+ click: function () {
+ return reject ? Promise.reject() : Promise.resolve();
+ },
+ }],
+ }).open();
+
+ await testUtils.nextTick();
+ assert.strictEqual($('.modal').length, 1, "should have a modal displayed");
+
+ await testUtils.dom.click($('.modal .o_form_button_magic'));
+ assert.strictEqual($('.modal').length, 1, "modal should still be opened");
+
+ reject = false;
+ await testUtils.dom.click($('.modal .o_form_button_magic'));
+ assert.strictEqual($('.modal').length, 0, "modal should be closed");
+
+ parent.destroy();
+ });
+
+});
+
+});