diff options
Diffstat (limited to 'addons/web/static/tests')
29 files changed, 18706 insertions, 0 deletions
diff --git a/addons/web/static/tests/boot_tests.js b/addons/web/static/tests/boot_tests.js new file mode 100644 index 00000000000..f317e77d2e5 --- /dev/null +++ b/addons/web/static/tests/boot_tests.js @@ -0,0 +1,21 @@ +(function() { +"use strict"; + +odoo.__DEBUG__.didLogInfo.then(function() { + + var modulesInfo = odoo.__DEBUG__.js_modules; + + QUnit.module('Odoo JS Modules'); + + QUnit.test('all modules are properly loaded', function(assert) { + assert.expect(2); + + assert.deepEqual(modulesInfo.missing, [], + "no js module should be missing"); + assert.deepEqual(modulesInfo.failed, [], + "no js module should have failed"); + }); + +}); + +})();
\ No newline at end of file diff --git a/addons/web/static/tests/chrome/pager_tests.js b/addons/web/static/tests/chrome/pager_tests.js new file mode 100644 index 00000000000..48c4ce6ed89 --- /dev/null +++ b/addons/web/static/tests/chrome/pager_tests.js @@ -0,0 +1,94 @@ +odoo.define('web.pager_tests', function (require) { +"use strict"; + +var Pager = require('web.Pager'); +var concurrency = require('web.concurrency'); + +QUnit.module('chrome', {}, function () { + + QUnit.module('Pager'); + + QUnit.test('basic stuff', function (assert) { + assert.expect(2); + + var pager = new Pager(null, 10, 1, 4); + pager.appendTo($('#qunit-fixture')); + + assert.strictEqual(pager.state.current_min, 1, + "current_min should be set to 1"); + + // click on next + pager.$('.o_pager_next').click(); + assert.strictEqual(pager.state.current_min, 5, + "current_min should now be 5"); + + pager.destroy(); + }); + + QUnit.test('edit the pager', function (assert) { + assert.expect(5); + + var pager = new Pager(null, 10, 1, 4); + pager.appendTo($('#qunit-fixture')); + + // enter edition + pager.$('.o_pager_value').click(); + assert.strictEqual(pager.$('input').length, 1, + "the pager should contain an input"); + assert.strictEqual(pager.$('input').val(), '1-4', + "the input should have correct value"); + + // change the limit + pager.$('input') + .val('1-6') + .trigger($.Event('keydown', {which: $.ui.keyCode.ENTER})); // trigger the change + assert.strictEqual(pager.state.limit, 6, + "the limit should have been updated"); + assert.strictEqual(pager.state.current_min, 1, + "the current_min should not have changed"); + assert.strictEqual(pager.$('.o_pager_value').text(), '1-6', + "the input should have correct value"); + + pager.destroy(); + }); + + QUnit.test('disabling the pager', function (assert) { + var done = assert.async(); + assert.expect(4); + + var pager = new Pager(null, 10, 1, 4); + pager.appendTo($('#qunit-fixture')); + + pager.disable(); + + // try to go to the next or previous pages + pager.$('.o_pager_next').click(); + assert.strictEqual(pager.state.current_min, 1, + "current_min should still be 1"); + pager.$('.o_pager_previous').click(); + assert.strictEqual(pager.state.current_min, 1, + "current_min should still be 1"); + + // try to change the limit + pager.$('.o_pager_value').click(); + assert.strictEqual(pager.$('input').length, 0, + "the pager should not contain an input"); + + // a common use is to disable the pager before reloading the data, and + // re-enable it once they have been loaded + // the following imulates this situation + pager.on('pager_changed', null, function () { + pager.disable(); + concurrency.delay(0).then(function () { + assert.ok(pager.disabled, "pager should still be disabled"); + pager.destroy(); + done(); + }); + }); + pager.enable(); + pager.$('.o_pager_next').click(); + + }); +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/chrome/search_tests.js b/addons/web/static/tests/chrome/search_tests.js new file mode 100644 index 00000000000..c2d290134ed --- /dev/null +++ b/addons/web/static/tests/chrome/search_tests.js @@ -0,0 +1,172 @@ +odoo.define('web.search_tests', function (require) { +"use strict"; + +var SearchView = require('web.SearchView'); + +QUnit.module('chrome', {}, function () { + + QUnit.module('Search View'); + + QUnit.test('Adding a facet to the query creates a facet and a value', function (assert) { + assert.expect(3); + + var query = new SearchView.SearchQuery(); + var field = {}; + query.add({ + category: 'Foo', + field: field, + values: [{label: 'Value', value: 3}] + }); + + var facet = query.at(0); + assert.strictEqual(facet.get('category'), 'Foo'); + assert.strictEqual(facet.get('field'), field); + assert.deepEqual(facet.get('values'), [{label: 'Value', value: 3}]); + }); + + QUnit.test('Adding two facets', function (assert) { + assert.expect(3); + + var query = new SearchView.SearchQuery(); + query.add([ + { category: 'Foo', field: {}, values: [{label: 'Value', value: 3}] }, + { category: 'Bar', field: {}, values: [{label: 'Value 2', value: 4}] } + ]); + + assert.strictEqual(query.length, 2); + assert.strictEqual(query.at(0).values.length, 1); + assert.strictEqual(query.at(1).values.length, 1); + }); + + QUnit.test('If a facet already exists, add values to it', function (assert) { + assert.expect(2); + + var query = new SearchView.SearchQuery(); + var field = {}; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + assert.strictEqual(query.length, 1, "adding an existing facet should merge new values into old facet"); + var facet = query.at(0); + assert.deepEqual(facet.get('values'), [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]); + }); + + QUnit.test('Facet being implicitly changed should trigger change, not add', function (assert) { + assert.expect(2); + + var query = new SearchView.SearchQuery(); + var field = {}, added = false, changed = false; + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.on('add', function () { added = true; }) + .on('change', function () { changed = true; }); + query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + assert.ok(!added, "query.add adding values to a facet should not trigger an add"); + assert.ok(changed, "query.add adding values to a facet should not trigger a change"); + }); + + QUnit.test('Toggling a facet, value which does not exist should add it', function (assert) { + assert.expect(3); + + var query = new SearchView.SearchQuery(); + var field = {}; + query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + assert.strictEqual(query.length, 1, "Should have created a single facet"); + var facet = query.at(0); + assert.strictEqual(facet.values.length, 1, "Facet should have a single value"); + assert.deepEqual(facet.get('values'), [{label: 'V1', value: 0}], + "Facet's value should match input"); + }); + + QUnit.test('Toggling a facet which exists with a value which does not should add the value to the facet', function (assert) { + assert.expect(3); + + var field = {}; + var query = new SearchView.SearchQuery(); + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + assert.strictEqual(query.length, 1, "Should have edited the existing facet"); + var facet = query.at(0); + assert.strictEqual(facet.values.length, 2, "Should have added the value to the existing facet"); + assert.deepEqual(facet.get('values'), [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]); + }); + + QUnit.test('Toggling a facet which exists with a value which does as well should remove the value from the facet', function (assert) { + assert.expect(3); + + var field = {}; + var query = new SearchView.SearchQuery(); + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); + + assert.strictEqual(query.length, 1, 'Should have the same single facet'); + var facet = query.at(0); + assert.strictEqual(facet.values.length, 1, "Should only have one value left in the facet"); + assert.deepEqual(facet.get('values'), [ + {label: 'V1', value: 0} + ]); + }); + + QUnit.test('Toggling off the last value of a facet should remove the facet', function (assert) { + assert.expect(1); + + var field = {}; + var query = new SearchView.SearchQuery(); + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + assert.strictEqual(query.length, 0, 'Should have removed the facet'); + }); + + QUnit.test('Intermediate emptiness should not remove the facet', function (assert) { + assert.expect(3); + + var field = {}; + var query = new SearchView.SearchQuery(); + query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); + + query.toggle({category: 'A', field: field, values: [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]}); + + assert.strictEqual(query.length, 1, 'Should not have removed the facet'); + var facet = query.at(0); + assert.strictEqual(facet.values.length, 1, "Should have one value"); + assert.deepEqual(facet.get('values'), [ + {label: 'V2', value: 1} + ]); + }); + + QUnit.test('Reseting with multiple facets should still work to load defaults', function (assert) { + assert.expect(3); + + var query = new SearchView.SearchQuery(); + var field = {}; + query.reset([ + {category: 'A', field: field, values: [{label: 'V1', value: 0}]}, + {category: 'A', field: field, values: [{label: 'V2', value: 1}]}]); + + assert.strictEqual(query.length, 1, 'Should have created a single facet'); + assert.strictEqual(query.at(0).values.length, 2, 'the facet should have merged two values'); + assert.deepEqual(query.at(0).get('values'), [ + {label: 'V1', value: 0}, + {label: 'V2', value: 1} + ]); + }); + + // TODO add a test to check that combining two filters with a or normalizes the two filters before + // Note: maybe domain.js will be improved to make this easier +}); +}); diff --git a/addons/web/static/tests/core/class_tests.js b/addons/web/static/tests/core/class_tests.js new file mode 100644 index 00000000000..438b137e6a1 --- /dev/null +++ b/addons/web/static/tests/core/class_tests.js @@ -0,0 +1,168 @@ +odoo.define('web.class_tests', function (require) { +"use strict"; + +var Class = require('web.Class'); + +QUnit.module('core', {}, function () { + + QUnit.module('Class'); + + + QUnit.test('Basic class creation', function (assert) { + assert.expect(2); + + var C = Class.extend({ + foo: function () { + return this.somevar; + } + }); + var i = new C(); + i.somevar = 3; + + assert.ok(i instanceof C); + assert.strictEqual(i.foo(), 3); + }); + + QUnit.test('Class initialization', function (assert) { + assert.expect(2); + + var C1 = Class.extend({ + init: function () { + this.foo = 3; + } + }); + var C2 = Class.extend({ + init: function (arg) { + this.foo = arg; + } + }); + + var i1 = new C1(), + i2 = new C2(42); + + assert.strictEqual(i1.foo, 3); + assert.strictEqual(i2.foo, 42); + }); + + QUnit.test('Inheritance', function (assert) { + assert.expect(3); + + var C0 = Class.extend({ + foo: function () { + return 1; + } + }); + var C1 = C0.extend({ + foo: function () { + return 1 + this._super(); + } + }); + var C2 = C1.extend({ + foo: function () { + return 1 + this._super(); + } + }); + + assert.strictEqual(new C0().foo(), 1); + assert.strictEqual(new C1().foo(), 2); + assert.strictEqual(new C2().foo(), 3); + }); + + QUnit.test('In-place extension', function (assert) { + assert.expect(4); + + var C0 = Class.extend({ + foo: function () { + return 3; + }, + qux: function () { + return 3; + }, + bar: 3 + }); + + C0.include({ + foo: function () { + return 5; + }, + qux: function () { + return 2 + this._super(); + }, + bar: 5, + baz: 5 + }); + + assert.strictEqual(new C0().bar, 5); + assert.strictEqual(new C0().baz, 5); + assert.strictEqual(new C0().foo(), 5); + assert.strictEqual(new C0().qux(), 5); + }); + + QUnit.test('In-place extension and inheritance', function (assert) { + assert.expect(4); + + var C0 = Class.extend({ + foo: function () { return 1; }, + bar: function () { return 1; } + }); + var C1 = C0.extend({ + foo: function () { return 1 + this._super(); } + }); + assert.strictEqual(new C1().foo(), 2); + assert.strictEqual(new C1().bar(), 1); + + C1.include({ + foo: function () { return 2 + this._super(); }, + bar: function () { return 1 + this._super(); } + }); + assert.strictEqual(new C1().foo(), 4); + assert.strictEqual(new C1().bar(), 2); + }); + + QUnit.test('In-place extensions alter existing instances', function (assert) { + assert.expect(4); + + var C0 = Class.extend({ + foo: function () { return 1; }, + bar: function () { return 1; } + }); + var i = new C0(); + assert.strictEqual(i.foo(), 1); + assert.strictEqual(i.bar(), 1); + + C0.include({ + foo: function () { return 2; }, + bar: function () { return 2 + this._super(); } + }); + assert.strictEqual(i.foo(), 2); + assert.strictEqual(i.bar(), 3); + }); + + QUnit.test('In-place extension of subclassed types', function (assert) { + assert.expect(3); + + var C0 = Class.extend({ + foo: function () { return 1; }, + bar: function () { return 1; } + }); + var C1 = C0.extend({ + foo: function () { return 1 + this._super(); }, + bar: function () { return 1 + this._super(); } + }); + var i = new C1(); + + assert.strictEqual(i.foo(), 2); + + C0.include({ + foo: function () { return 2; }, + bar: function () { return 2 + this._super(); } + }); + + assert.strictEqual(i.foo(), 3); + assert.strictEqual(i.bar(), 4); + }); + + +}); + +}); diff --git a/addons/web/static/tests/core/concurrency_tests.js b/addons/web/static/tests/core/concurrency_tests.js new file mode 100644 index 00000000000..62b9f90bcfb --- /dev/null +++ b/addons/web/static/tests/core/concurrency_tests.js @@ -0,0 +1,256 @@ +odoo.define('web.concurrency_tests', function (require) { +"use strict"; + +var concurrency = require('web.concurrency'); + +QUnit.module('core', {}, function () { + + QUnit.module('concurrency'); + + + QUnit.test('mutex: simple scheduling', function (assert) { + assert.expect(6); + + var m = new concurrency.Mutex(); + + var def1 = $.Deferred(), + def2 = $.Deferred(); + + var p1 = m.exec(function () { return def1; }); + var p2 = m.exec(function () { return def2; }); + + assert.strictEqual(p1.state(), "pending"); + assert.strictEqual(p2.state(), "pending"); + + def1.resolve(); + assert.strictEqual(p1.state(), "resolved"); + assert.strictEqual(p2.state(), "pending"); + + def2.resolve(); + assert.strictEqual(p1.state(), "resolved"); + assert.strictEqual(p2.state(), "resolved"); + }); + + QUnit.test('mutex: simpleScheduling2', function (assert) { + assert.expect(6); + + var m = new concurrency.Mutex(); + + var def1 = $.Deferred(), + def2 = $.Deferred(); + + var p1 = m.exec(function() { return def1; }); + var p2 = m.exec(function() { return def2; }); + + assert.strictEqual(p1.state(), "pending"); + assert.strictEqual(p2.state(), "pending"); + def2.resolve(); + + assert.strictEqual(p1.state(), "pending"); + assert.strictEqual(p2.state(), "pending"); + + def1.resolve(); + assert.strictEqual(p1.state(), "resolved"); + assert.strictEqual(p2.state(), "resolved"); + }); + + QUnit.test('mutex: reject', function (assert) { + assert.expect(12); + + var m = new concurrency.Mutex(); + + var def1 = $.Deferred(), + def2 = $.Deferred(), + def3 = $.Deferred(); + + var p1 = m.exec(function() {return def1;}); + var p2 = m.exec(function() {return def2;}); + var p3 = m.exec(function() {return def3;}); + + assert.strictEqual(p1.state(), "pending"); + assert.strictEqual(p2.state(), "pending"); + assert.strictEqual(p3.state(), "pending"); + + def1.resolve(); + assert.strictEqual(p1.state(), "resolved"); + assert.strictEqual(p2.state(), "pending"); + assert.strictEqual(p3.state(), "pending"); + + def2.reject(); + assert.strictEqual(p1.state(), "resolved"); + assert.strictEqual(p2.state(), "rejected"); + assert.strictEqual(p3.state(), "pending"); + + def3.resolve(); + assert.strictEqual(p1.state(), "resolved"); + assert.strictEqual(p2.state(), "rejected"); + assert.strictEqual(p3.state(), "resolved"); + }); + + QUnit.test('DropMisordered: resolve all correctly ordered, sync', function (assert) { + assert.expect(1); + + var dm = new concurrency.DropMisordered(), + flag = false; + + var d1 = $.Deferred(), + d2 = $.Deferred(); + + var r1 = dm.add(d1), + r2 = dm.add(d2); + + $.when(r1, r2).done(function () { + flag = true; + }); + + d1.resolve(); + d2.resolve(); + + assert.ok(flag); + }); + + QUnit.test("DropMisordered: don't resolve mis-ordered, sync", function (assert) { + assert.expect(4); + + var dm = new concurrency.DropMisordered(), + done1 = false, + done2 = false, + fail1 = false, + fail2 = false; + + var d1 = $.Deferred(), + d2 = $.Deferred(); + + dm.add(d1).done(function () { done1 = true; }) + .fail(function () { fail1 = true; }); + dm.add(d2).done(function () { done2 = true; }) + .fail(function () { fail2 = true; }); + + d2.resolve(); + d1.resolve(); + + // d1 is in limbo + assert.ok(!done1); + assert.ok(!fail1); + + // d2 is resolved + assert.ok(done2); + assert.ok(!fail2); + }); + + QUnit.test('DropMisordered: fail mis-ordered flag, sync', function (assert) { + assert.expect(4); + + var dm = new concurrency.DropMisordered(true), + done1 = false, + done2 = false, + fail1 = false, + fail2 = false; + + var d1 = $.Deferred(), + d2 = $.Deferred(); + + dm.add(d1).done(function () { done1 = true; }) + .fail(function () { fail1 = true; }); + dm.add(d2).done(function () { done2 = true; }) + .fail(function () { fail2 = true; }); + + d2.resolve(); + d1.resolve(); + + // d1 is in limbo + assert.ok(!done1); + assert.ok(fail1); + + // d2 is resolved + assert.ok(done2); + assert.ok(!fail2); + + }); + + QUnit.test('DropMisordered: resolve all correctly ordered, async', function (assert) { + var done = assert.async(); + assert.expect(1); + + var dm = new concurrency.DropMisordered(); + + var d1 = $.Deferred(), + d2 = $.Deferred(); + + var r1 = dm.add(d1), + r2 = dm.add(d2); + + setTimeout(function () { d1.resolve(); }, 10); + setTimeout(function () { d2.resolve(); }, 20); + + $.when(r1, r2).done(function () { + assert.ok(true); + done(); + }); + }); + + QUnit.test("DropMisordered: don't resolve mis-ordered, async", function (assert) { + var done = assert.async(); + assert.expect(4); + + var dm = new concurrency.DropMisordered(), + done1 = false, done2 = false, + fail1 = false, fail2 = false; + + var d1 = $.Deferred(), + d2 = $.Deferred(); + + dm.add(d1).done(function () { done1 = true; }) + .fail(function () { fail1 = true; }); + dm.add(d2).done(function () { done2 = true; }) + .fail(function () { fail2 = true; }); + + setTimeout(function () { d1.resolve(); }, 20); + setTimeout(function () { d2.resolve(); }, 10); + + setTimeout(function () { + // d1 is in limbo + assert.ok(!done1); + assert.ok(!fail1); + + // d2 is resolved + assert.ok(done2); + assert.ok(!fail2); + done(); + }, 30); + }); + + QUnit.test('DropMisordered: fail mis-ordered flag, async', function (assert) { + var done = assert.async(); + assert.expect(4); + + var dm = new concurrency.DropMisordered(true), + done1 = false, done2 = false, + fail1 = false, fail2 = false; + + var d1 = $.Deferred(), + d2 = $.Deferred(); + + dm.add(d1).done(function () { done1 = true; }) + .fail(function () { fail1 = true; }); + dm.add(d2).done(function () { done2 = true; }) + .fail(function () { fail2 = true; }); + + setTimeout(function () { d1.resolve(); }, 20); + setTimeout(function () { d2.resolve(); }, 10); + + setTimeout(function () { + // d1 is failed + assert.ok(!done1); + assert.ok(fail1); + + // d2 is resolved + assert.ok(done2); + assert.ok(!fail2); + done(); + }, 30); + }); + +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/core/domain_tests.js b/addons/web/static/tests/core/domain_tests.js new file mode 100644 index 00000000000..001eae2d6b9 --- /dev/null +++ b/addons/web/static/tests/core/domain_tests.js @@ -0,0 +1,62 @@ +odoo.define('web.domain_tests', function (require) { +"use strict"; + +var Domain = require('web.Domain'); + +QUnit.module('core', {}, function () { + + QUnit.module('domain'); + + QUnit.test("basic", function (assert) { + assert.expect(3); + + var fields = { + a: 3, + group_method: 'line', + select1: 'day', + rrule_type: 'monthly', + }; + assert.ok(new Domain([['a', '=', 3]]).compute(fields)); + assert.ok(new Domain([['group_method','!=','count']]).compute(fields)); + assert.ok(new Domain([['select1','=','day'], ['rrule_type','=','monthly']]).compute(fields)); + }); + + QUnit.test("or", function (assert) { + assert.expect(3); + + var web = { + section_id: null, + user_id: null, + member_ids: null, + }; + var currentDomain = [ + '|', + ['section_id', '=', 42], + '|', + ['user_id', '=', 3], + ['member_ids', 'in', [3]] + ]; + assert.ok(new Domain(currentDomain).compute(_.extend({}, web, {section_id: 42}))); + assert.ok(new Domain(currentDomain).compute(_.extend({}, web, {user_id: 3}))); + assert.ok(new Domain(currentDomain).compute(_.extend({}, web, {member_ids: 3}))); + }); + + QUnit.test("not", function (assert) { + assert.expect(2); + + var fields = { + a: 5, + group_method: 'line', + }; + assert.ok(new Domain(['!', ['a', '=', 3]]).compute(fields)); + assert.ok(new Domain(['!', ['group_method','=','count']]).compute(fields)); + }); + + QUnit.test("domains initialized with a number", function (assert) { + assert.expect(2); + + assert.ok(new Domain(1).compute({})); + assert.notOk(new Domain(0).compute({})); + }); +}); +}); diff --git a/addons/web/static/tests/core/pyeval_tests.js b/addons/web/static/tests/core/pyeval_tests.js new file mode 100644 index 00000000000..0bd9a7151dd --- /dev/null +++ b/addons/web/static/tests/core/pyeval_tests.js @@ -0,0 +1,1009 @@ +odoo.define('web.pyEval_tests', function(require) { +"use strict"; + +var Context = require('web.Context'); +var pyEval = require('web.pyeval'); +var time = require('web.time'); + +QUnit.module('core', function () { + + QUnit.module('PY.eval'); + + QUnit.test('simple python expression', function(assert) { + assert.expect(2); + + var result = pyEval.py_eval("True and False"); + assert.strictEqual(result, false, "should properly evaluate basic expression"); + result = pyEval.py_eval("a + b", {a: 1, b: 41}); + assert.strictEqual(result, 42, "should properly evaluate basic sum"); + }); + + QUnit.test('not prefix', function (assert) { + assert.expect(3); + + assert.ok(py.eval('not False')); + assert.ok(py.eval('not foo', {foo: false})); + assert.ok(py.eval('not a in b', {a: 3, b: [1, 2, 4, 8]})); + }); + + + function makeTimeCheck (assert) { + var context = pyEval.context(); + return function (expr, func, message) { + // evaluate expr between two calls to new Date(), and check that + // the result is between the transformed dates + var d0 = new Date(); + var result = py.eval(expr, context); + var d1 = new Date(); + assert.ok(func(d0) <= result && result <= func(d1), message); + }; + } + + // Port from pypy/lib_pypy/test_datetime.py + function makeEq(assert, c2) { + var ctx = pyEval.context(); + var c = _.extend({ td: ctx.datetime.timedelta }, c2 || {}); + return function (a, b, message) { + assert.ok(py.eval(a + ' == ' + b, c), message); + }; + } + + QUnit.test('strftime', function (assert) { + assert.expect(3); + + var check = makeTimeCheck(assert); + + check("time.strftime('%Y')", function(d) { + return String(d.getFullYear()); + }); + + check("time.strftime('%Y')+'-01-30'", function(d) { + return String(d.getFullYear()) + '-01-30'; + }); + + check("time.strftime('%Y-%m-%d %H:%M:%S')", function(d) { + return _.str.sprintf('%04d-%02d-%02d %02d:%02d:%02d', + d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), + d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()); + }); + }); + + QUnit.test('context_today', function (assert) { + assert.expect(1); + + var check = makeTimeCheck(assert, pyEval); + + check("context_today().strftime('%Y-%m-%d')", function(d) { + return String(_.str.sprintf('%04d-%02d-%02d', + d.getFullYear(), d.getMonth() + 1, d.getDate())); + }); + }); + + QUnit.test('timedelta.test_constructor', function (assert) { + assert.expect(16); + + var eq = makeEq(assert); + + // keyword args to constructor + eq('td()', 'td(weeks=0, days=0, hours=0, minutes=0, seconds=0, ' + + 'milliseconds=0, microseconds=0)'); + eq('td(1)', 'td(days=1)'); + eq('td(0, 1)', 'td(seconds=1)'); + eq('td(0, 0, 1)', 'td(microseconds=1)'); + eq('td(weeks=1)', 'td(days=7)'); + eq('td(days=1)', 'td(hours=24)'); + eq('td(hours=1)', 'td(minutes=60)'); + eq('td(minutes=1)', 'td(seconds=60)'); + eq('td(seconds=1)', 'td(milliseconds=1000)'); + eq('td(milliseconds=1)', 'td(microseconds=1000)'); + + // Check float args to constructor + eq('td(weeks=1.0/7)', 'td(days=1)'); + eq('td(days=1.0/24)', 'td(hours=1)'); + eq('td(hours=1.0/60)', 'td(minutes=1)'); + eq('td(minutes=1.0/60)', 'td(seconds=1)'); + eq('td(seconds=0.001)', 'td(milliseconds=1)'); + eq('td(milliseconds=0.001)', 'td(microseconds=1)'); + }); + + QUnit.test('timedelta.test_computations', function (assert) { + assert.expect(28); + + var c = pyEval.context(); + var zero = py.float.fromJSON(0); + var eq = makeEq(assert, { + // one week + a: py.PY_call(c.datetime.timedelta, [ + py.float.fromJSON(7)]), + // one minute + b: py.PY_call(c.datetime.timedelta, [ + zero, py.float.fromJSON(60)]), + // one millisecond + c: py.PY_call(c.datetime.timedelta, [ + zero, zero, py.float.fromJSON(1000)]), + }); + + eq('a+b+c', 'td(7, 60, 1000)'); + eq('a-b', 'td(6, 24*3600 - 60)'); + eq('-a', 'td(-7)'); + eq('+a', 'td(7)'); + eq('-b', 'td(-1, 24*3600 - 60)'); + eq('-c', 'td(-1, 24*3600 - 1, 999000)'); + // eq('abs(a)', 'a'); + // eq('abs(-a)', 'a'); + eq('td(6, 24*3600)', 'a'); + eq('td(0, 0, 60*1000000)', 'b'); + eq('a*10', 'td(70)'); + eq('a*10', '10*a'); + // eq('a*10L', '10*a'); + eq('b*10', 'td(0, 600)'); + eq('10*b', 'td(0, 600)'); + // eq('b*10L', 'td(0, 600)'); + eq('c*10', 'td(0, 0, 10000)'); + eq('10*c', 'td(0, 0, 10000)'); + // eq('c*10L', 'td(0, 0, 10000)'); + eq('a*-1', '-a'); + eq('b*-2', '-b-b'); + eq('c*-2', '-c+-c'); + eq('b*(60*24)', '(b*60)*24'); + eq('b*(60*24)', '(60*b)*24'); + eq('c*1000', 'td(0, 1)'); + eq('1000*c', 'td(0, 1)'); + eq('a//7', 'td(1)'); + eq('b//10', 'td(0, 6)'); + eq('c//1000', 'td(0, 0, 1)'); + eq('a//10', 'td(0, 7*24*360)'); + eq('a//3600000', 'td(0, 0, 7*24*1000)'); + + // Issue #11576 + eq('td(999999999, 86399, 999999) - td(999999999, 86399, 999998)', 'td(0, 0, 1)'); + eq('td(999999999, 1, 1) - td(999999999, 1, 0)', + 'td(0, 0, 1)'); + }); + + QUnit.test('timedelta.test_basic_attributes', function (assert) { + assert.expect(3); + + var ctx = pyEval.context(); + assert.strictEqual(py.eval('datetime.timedelta(1, 7, 31).days', ctx), 1); + assert.strictEqual(py.eval('datetime.timedelta(1, 7, 31).seconds', ctx), 7); + assert.strictEqual(py.eval('datetime.timedelta(1, 7, 31).microseconds', ctx), 31); + }); + + QUnit.test('timedelta.test_total_seconds', function (assert) { + assert.expect(6); + + var c = { timedelta: pyEval.context().datetime.timedelta }; + assert.strictEqual(py.eval('timedelta(365).total_seconds()', c), 31536000); + assert.strictEqual( + py.eval('timedelta(seconds=123456.789012).total_seconds()', c), + 123456.789012); + assert.strictEqual( + py.eval('timedelta(seconds=-123456.789012).total_seconds()', c), + -123456.789012); + assert.strictEqual( + py.eval('timedelta(seconds=0.123456).total_seconds()', c), 0.123456); + assert.strictEqual(py.eval('timedelta().total_seconds()', c), 0); + assert.strictEqual( + py.eval('timedelta(seconds=1000000).total_seconds()', c), 1e6); + }); + + QUnit.test('timedelta.test_str', function (assert) { + assert.expect(10); + + var c = { td: pyEval.context().datetime.timedelta }; + + assert.strictEqual(py.eval('str(td(1))', c), "1 day, 0:00:00"); + assert.strictEqual(py.eval('str(td(-1))', c), "-1 day, 0:00:00"); + assert.strictEqual(py.eval('str(td(2))', c), "2 days, 0:00:00"); + assert.strictEqual(py.eval('str(td(-2))', c), "-2 days, 0:00:00"); + + assert.strictEqual(py.eval('str(td(hours=12, minutes=58, seconds=59))', c), + "12:58:59"); + assert.strictEqual(py.eval('str(td(hours=2, minutes=3, seconds=4))', c), + "2:03:04"); + assert.strictEqual( + py.eval('str(td(weeks=-30, hours=23, minutes=12, seconds=34))', c), + "-210 days, 23:12:34"); + + assert.strictEqual(py.eval('str(td(milliseconds=1))', c), "0:00:00.001000"); + assert.strictEqual(py.eval('str(td(microseconds=3))', c), "0:00:00.000003"); + + assert.strictEqual( + py.eval('str(td(days=999999999, hours=23, minutes=59, seconds=59, microseconds=999999))', c), + "999999999 days, 23:59:59.999999"); + }); + + QUnit.test('timedelta.test_massive_normalization', function (assert) { + assert.expect(3); + + var td = py.PY_call( + pyEval.context().datetime.timedelta, + {microseconds: py.float.fromJSON(-1)}); + assert.strictEqual(td.days, -1); + assert.strictEqual(td.seconds, 24 * 3600 - 1); + assert.strictEqual(td.microseconds, 999999); + }); + + QUnit.test('timedelta.test_bool', function (assert) { + assert.expect(5); + + var c = { td: pyEval.context().datetime.timedelta }; + assert.ok(py.eval('bool(td(1))', c)); + assert.ok(py.eval('bool(td(0, 1))', c)); + assert.ok(py.eval('bool(td(0, 0, 1))', c)); + assert.ok(py.eval('bool(td(microseconds=1))', c)); + assert.ok(py.eval('bool(not td(0))', c)); + }); + + QUnit.test('date.test_computations', function (assert) { + assert.expect(31); + + var d = pyEval.context().datetime; + + var a = d.date.fromJSON(2002, 1, 31); + var b = d.date.fromJSON(1956, 1, 31); + assert.strictEqual( + py.eval('(a - b).days', {a: a, b: b}), + 46 * 365 + 12); + assert.strictEqual(py.eval('(a - b).seconds', {a: a, b: b}), 0); + assert.strictEqual(py.eval('(a - b).microseconds', {a: a, b: b}), 0); + + var day = py.PY_call(d.timedelta, [py.float.fromJSON(1)]); + var week = py.PY_call(d.timedelta, [py.float.fromJSON(7)]); + a = d.date.fromJSON(2002, 3, 2); + var ctx = { + a: a, + day: day, + week: week, + date: d.date + }; + assert.ok(py.eval('a + day == date(2002, 3, 3)', ctx)); + assert.ok(py.eval('day + a == date(2002, 3, 3)', ctx)); // 5 + assert.ok(py.eval('a - day == date(2002, 3, 1)', ctx)); + assert.ok(py.eval('-day + a == date(2002, 3, 1)', ctx)); + assert.ok(py.eval('a + week == date(2002, 3, 9)', ctx)); + assert.ok(py.eval('a - week == date(2002, 2, 23)', ctx)); + assert.ok(py.eval('a + 52*week == date(2003, 3, 1)', ctx)); // 10 + assert.ok(py.eval('a - 52*week == date(2001, 3, 3)', ctx)); + assert.ok(py.eval('(a + week) - a == week', ctx)); + assert.ok(py.eval('(a + day) - a == day', ctx)); + assert.ok(py.eval('(a - week) - a == -week', ctx)); + assert.ok(py.eval('(a - day) - a == -day', ctx)); // 15 + assert.ok(py.eval('a - (a + week) == -week', ctx)); + assert.ok(py.eval('a - (a + day) == -day', ctx)); + assert.ok(py.eval('a - (a - week) == week', ctx)); + assert.ok(py.eval('a - (a - day) == day', ctx)); + + assert.throws(function () { + py.eval('a + 1', ctx); + }, /^Error: TypeError:/); //20 + assert.throws(function () { + py.eval('a - 1', ctx); + }, /^Error: TypeError:/); + assert.throws(function () { + py.eval('1 + a', ctx); + }, /^Error: TypeError:/); + assert.throws(function () { + py.eval('1 - a', ctx); + }, /^Error: TypeError:/); + + // delta - date is senseless. + assert.throws(function () { + py.eval('day - a', ctx); + }, /^Error: TypeError:/); + // mixing date and (delta or date) via * or // is senseless + assert.throws(function () { + py.eval('day * a', ctx); + }, /^Error: TypeError:/); // 25 + assert.throws(function () { + py.eval('a * day', ctx); + }, /^Error: TypeError:/); + assert.throws(function () { + py.eval('day // a', ctx); + }, /^Error: TypeError:/); + assert.throws(function () { + py.eval('a // day', ctx); + }, /^Error: TypeError:/); + assert.throws(function () { + py.eval('a * a', ctx); + }, /^Error: TypeError:/); + assert.throws(function () { + py.eval('a // a', ctx); + }, /^Error: TypeError:/); // 30 + // date + date is senseless + assert.throws(function () { + py.eval('a + a', ctx); + }, /^Error: TypeError:/); + + }); + + QUnit.test('relativedelta', function (assert) { + assert.expect(5); + + assert.strictEqual( + py.eval("(datetime.date(2012, 2, 15) + relativedelta(days=-1)).strftime('%Y-%m-%d 23:59:59')", + pyEval.context()), + "2012-02-14 23:59:59"); + assert.strictEqual( + py.eval("(datetime.date(2012, 2, 15) + relativedelta(days=1)).strftime('%Y-%m-%d')", + pyEval.context()), + "2012-02-16"); + assert.strictEqual( + py.eval("(datetime.date(2012, 2, 15) + relativedelta(days=-1)).strftime('%Y-%m-%d')", + pyEval.context()), + "2012-02-14"); + assert.strictEqual( + py.eval("(datetime.date(2012, 2, 1) + relativedelta(days=-1)).strftime('%Y-%m-%d')", + pyEval.context()), + '2012-01-31'); + assert.strictEqual( + py.eval("(datetime.date(2015,2,5)+relativedelta(days=-6,weekday=0)).strftime('%Y-%m-%d')", + pyEval.context()), + '2015-02-02'); + }); + + QUnit.test('datetime.tojson', function (assert) { + assert.expect(7); + + var result = py.eval( + 'datetime.datetime(2012, 2, 15, 1, 7, 31)', + pyEval.context()); + + assert.ok(result instanceof Date); + assert.strictEqual(result.getFullYear(), 2012); + assert.strictEqual(result.getMonth(), 1); + assert.strictEqual(result.getDate(), 15); + assert.strictEqual(result.getHours(), 1); + assert.strictEqual(result.getMinutes(), 7); + assert.strictEqual(result.getSeconds(), 31); + }); + + QUnit.test('datetime.combine', function (assert) { + assert.expect(2); + + var result = py.eval( + 'datetime.datetime.combine(datetime.date(2012, 2, 15),' + + ' datetime.time(1, 7, 13))' + + ' .strftime("%Y-%m-%d %H:%M:%S")', + pyEval.context()); + assert.strictEqual(result, "2012-02-15 01:07:13"); + + result = py.eval( + 'datetime.datetime.combine(datetime.date(2012, 2, 15),' + + ' datetime.time())' + + ' .strftime("%Y-%m-%d %H:%M:%S")', + pyEval.context()); + assert.strictEqual(result, '2012-02-15 00:00:00'); + }); + + QUnit.test('datetime.replace', function (assert) { + assert.expect(1); + + var result = py.eval( + 'datetime.datetime(2012, 2, 15, 1, 7, 13)' + + ' .replace(hour=0, minute=0, second=0)' + + ' .strftime("%Y-%m-%d %H:%M:%S")', + pyEval.context()); + assert.strictEqual(result, "2012-02-15 00:00:00"); + }); + + QUnit.module('pyeval (eval domain contexts)', { + beforeEach: function() { + this.user_context = { + uid: 1, + lang: 'en_US', + tz: false, + }; + }, + }); + + + QUnit.test('empty, basic', function (assert) { + assert.expect(3); + + var result = pyEval.eval_domains_and_contexts({ + contexts: [this.user_context], + domains: [], + }); + + // default values for new db + assert.deepEqual(result.context, { + lang: 'en_US', + tz: false, + uid: 1 + }); + assert.deepEqual(result.domain, []); + assert.deepEqual(result.group_by, []); + }); + + + QUnit.test('context_merge_00', function (assert) { + assert.expect(1); + + var ctx = [ + { + "__contexts": [ + { "lang": "en_US", "tz": false, "uid": 1 }, + { + "active_id": 8, + "active_ids": [ 8 ], + "active_model": "sale.order", + "bin_raw": true, + "default_composition_mode": "comment", + "default_model": "sale.order", + "default_res_id": 8, + "default_template_id": 18, + "default_use_template": true, + "edi_web_url_view": "faaaake", + "lang": "en_US", + "mark_so_as_sent": null, + "show_address": null, + "tz": false, + "uid": null + }, + {} + ], + "__eval_context": null, + "__ref": "compound_context" + }, + { "active_id": 9, "active_ids": [ 9 ], "active_model": "mail.compose.message" } + ]; + var result = pyEval.eval_domains_and_contexts({ + contexts: ctx, + domins: [], + }); + + assert.deepEqual(result.context, { + active_id: 9, + active_ids: [9], + active_model: 'mail.compose.message', + bin_raw: true, + default_composition_mode: 'comment', + default_model: 'sale.order', + default_res_id: 8, + default_template_id: 18, + default_use_template: true, + edi_web_url_view: "faaaake", + lang: 'en_US', + mark_so_as_sent: null, + show_address: null, + tz: false, + uid: null + }); + + }); + + QUnit.test('context_merge_01', function (assert) { + assert.expect(1); + + var ctx = [{ + "__contexts": [ + { + "lang": "en_US", + "tz": false, + "uid": 1 + }, + { + "default_attachment_ids": [], + "default_body": "", + "default_content_subtype": "html", + "default_model": "res.users", + "default_parent_id": false, + "default_res_id": 1 + }, + {} + ], + "__eval_context": null, + "__ref": "compound_context" + }]; + var result = pyEval.eval_domains_and_contexts({ + contexts: ctx, + domains: [], + }); + + assert.deepEqual(result.context, { + "default_attachment_ids": [], + "default_body": "", + "default_content_subtype": "html", + "default_model": "res.users", + "default_parent_id": false, + "default_res_id": 1, + "lang": "en_US", + "tz": false, + "uid": 1 + }); + }); + + QUnit.test('domain with time', function (assert) { + assert.expect(1); + + var result = pyEval.eval_domains_and_contexts({ + domains: [ + [['type', '=', 'contract']], + ["|", ["state", "in", ["open", "draft"]], [["type", "=", "contract"], ["state", "=", "pending"]]], + "['|', '&', ('date', '!=', False), ('date', '<=', time.strftime('%Y-%m-%d')), ('is_overdue_quantity', '=', True)]", + [['user_id', '=', 1]] + ], + contexts: [], + }); + + var d = new Date(); + var today = _.str.sprintf("%04d-%02d-%02d", + d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()); + assert.deepEqual(result.domain, [ + ["type", "=", "contract"], + "|", ["state", "in", ["open", "draft"]], + [["type", "=", "contract"], + ["state", "=", "pending"]], + "|", + "&", ["date", "!=", false], + ["date", "<=", today], + ["is_overdue_quantity", "=", true], + ["user_id", "=", 1] + ]); + }); + + QUnit.test('conditional context', function (assert) { + assert.expect(2); + + var d = { + __ref: 'domain', + __debug: "[('company_id', '=', context.get('company_id',False))]" + }; + + var result1 = pyEval.eval_domains_and_contexts({ + domains: [d], + contexts: [], + }); + assert.deepEqual(result1.domain, [['company_id', '=', false]]); + + var result2 = pyEval.eval_domains_and_contexts({ + domains: [d], + contexts: [], + eval_context: {company_id: 42}, + }); + assert.deepEqual(result2.domain, [['company_id', '=', 42]]); + }); + + QUnit.test('substitution in context', function (assert) { + assert.expect(1); + + // setup(session); + var c = "{'default_opportunity_id': active_id, 'default_duration': 1.0, 'lng': lang}"; + var cc = new Context(c); + cc.set_eval_context({active_id: 42}); + var result = pyEval.eval_domains_and_contexts({ + domains:[], contexts: [this.user_context, cc] + }); + + assert.deepEqual(result.context, { + lang: "en_US", + tz: false, + uid: 1, + default_opportunity_id: 42, + default_duration: 1.0, + lng: "en_US" + }); + }); + + QUnit.test('date', function (assert) { + assert.expect(1); + + var d = "[('state','!=','cancel'),('opening_date','>',context_today().strftime('%Y-%m-%d'))]"; + var result = pyEval.eval_domains_and_contexts({ + domains: [d], + contexts: [], + }); + + var date = new Date(); + var today = _.str.sprintf("%04d-%02d-%02d", + date.getFullYear(), date.getMonth() + 1, date.getDate()); + assert.deepEqual(result.domain, [ + ['state', '!=', 'cancel'], + ['opening_date', '>', today] + ]); + }); + + QUnit.test('delta', function (assert) { + assert.expect(1); + + var d = "[('type','=','in'),('day','<=', time.strftime('%Y-%m-%d')),('day','>',(context_today()-datetime.timedelta(days=15)).strftime('%Y-%m-%d'))]"; + var result = pyEval.eval_domains_and_contexts({ + domains: [d], + contexts: [], + }); + var date = new Date(); + var today = _.str.sprintf("%04d-%02d-%02d", + date.getFullYear(), date.getMonth() + 1, date.getDate()); + date.setDate(date.getDate() - 15); + var ago_15_d = _.str.sprintf("%04d-%02d-%02d", + date.getFullYear(), date.getMonth() + 1, date.getDate()); + assert.deepEqual(result.domain, [ + ['type', '=', 'in'], + ['day', '<=', today], + ['day', '>', ago_15_d] + ]); + }); + + QUnit.test('horror from the deep', function (assert) { + assert.expect(1); + + var cs = [ + {"__ref": "compound_context", + "__contexts": [ + {"__ref": "context", "__debug": "{'k': 'foo,' + str(context.get('test_key', False))}"}, + {"__ref": "compound_context", + "__contexts": [ + {"lang": "en_US", "tz": false, "uid": 1}, + {"lang": "en_US", "tz": false, "uid": 1, + "active_model": "sale.order", "default_type": "out", + "show_address": 1, "contact_display": "partner_address", + "active_ids": [9], "active_id": 9}, + {} + ], "__eval_context": null }, + {"active_id": 8, "active_ids": [8], + "active_model": "stock.picking.out"}, + {"__ref": "context", "__debug": "{'default_ref': 'stock.picking.out,'+str(context.get('active_id', False))}", "__id": "54d6ad1d6c45"} + ], "__eval_context": null} + ]; + var result = pyEval.eval_domains_and_contexts({ + domains: [], + contexts: cs, + }); + + assert.deepEqual(result.context, { + k: 'foo,False', + lang: 'en_US', + tz: false, + uid: 1, + active_model: 'stock.picking.out', + active_id: 8, + active_ids: [8], + default_type: 'out', + show_address: 1, + contact_display: 'partner_address', + default_ref: 'stock.picking.out,8' + }); + }); + + QUnit.module('pyeval (contexts)'); + + QUnit.test('context_recursive', function (assert) { + assert.expect(3); + + var context_to_eval = [{ + __ref: 'context', + __debug: '{"foo": context.get("bar", "qux")}' + }]; + assert.deepEqual( + pyEval.eval('contexts', context_to_eval, {bar: "ok"}), + {foo: 'ok'}); + assert.deepEqual( + pyEval.eval('contexts', context_to_eval, {bar: false}), + {foo: false}); + assert.deepEqual( + pyEval.eval('contexts', context_to_eval), + {foo: 'qux'}); + }); + + QUnit.test('context_sequences', function (assert) { + assert.expect(1); + + // Context n should have base evaluation context + all of contexts + // 0..n-1 in its own evaluation context + var active_id = 4; + var result = pyEval.eval('contexts', [ + { + "__contexts": [ + { + "department_id": false, + "lang": "en_US", + "project_id": false, + "section_id": false, + "tz": false, + "uid": 1 + }, + { "search_default_create_uid": 1 }, + {} + ], + "__eval_context": null, + "__ref": "compound_context" + }, + { + "active_id": active_id, + "active_ids": [ active_id ], + "active_model": "purchase.requisition" + }, + { + "__debug": "{'record_id' : active_id}", + "__id": "63e8e9bff8a6", + "__ref": "context" + } + ]); + + assert.deepEqual(result, { + department_id: false, + lang: 'en_US', + project_id: false, + section_id: false, + tz: false, + uid: 1, + search_default_create_uid: 1, + active_id: active_id, + active_ids: [active_id], + active_model: 'purchase.requisition', + record_id: active_id + }); + }); + + QUnit.test('non-literal_eval_contexts', function (assert) { + assert.expect(1); + + var result = pyEval.eval('contexts', [{ + "__ref": "compound_context", + "__contexts": [ + {"__ref": "context", "__debug": "{'type':parent.type}", + "__id": "462b9dbed42f"} + ], + "__eval_context": { + "__ref": "compound_context", + "__contexts": [{ + "__ref": "compound_context", + "__contexts": [ + {"__ref": "context", "__debug": "{'type': type}", + "__id": "16a04ed5a194"} + ], + "__eval_context": { + "__ref": "compound_context", + "__contexts": [ + {"lang": "en_US", "tz": false, "uid": 1, + "journal_type": "sale", "section_id": false, + "default_type": "out_invoice", + "type": "out_invoice", "department_id": false}, + {"id": false, "journal_id": 10, + "number": false, "type": "out_invoice", + "currency_id": 1, "partner_id": 4, + "fiscal_position_id": false, + "date_invoice": false, "date": false, + "payment_term_id": false, "reference_type": "none", + "reference": false, "account_id": 440, + "name": false, "invoice_line_ids": [], + "tax_line_ids": [], "amount_untaxed": 0, + "amount_tax": 0, "reconciled": false, + "amount_total": 0, "state": "draft", + "residual": 0, "company_id": 1, + "date_due": false, "user_id": 1, + "partner_bank_id": false, "origin": false, + "move_id": false, "comment": false, + "payment_ids": [[6, false, []]], + "active_id": false, "active_ids": [], + "active_model": "account.invoice", + "parent": {}} + ], "__eval_context": null} + }, { + "id": false, + "product_id": 4, + "name": "[PC1] Basic PC", + "quantity": 1, + "uom_id": 1, + "price_unit": 100, + "account_id": 853, + "discount": 0, + "account_analytic_id": false, + "company_id": false, + "note": false, + "invoice_line_tax_ids": [[6, false, [1]]], + "active_id": false, + "active_ids": [], + "active_model": "account.invoice.line", + "parent": { + "id": false, "journal_id": 10, "number": false, + "type": "out_invoice", "currency_id": 1, + "partner_id": 4, "fiscal_position_id": false, + "date_invoice": false, "date": false, + "payment_term_id": false, "reference_type": "none", + "reference": false, "account_id": 440, "name": false, + "tax_line_ids": [], "amount_untaxed": 0, "amount_tax": 0, + "reconciled": false, "amount_total": 0, + "state": "draft", "residual": 0, "company_id": 1, + "date_due": false, "user_id": 1, + "partner_bank_id": false, "origin": false, + "move_id": false, "comment": false, + "payment_ids": [[6, false, []]]} + }], + "__eval_context": null + } + }]); + + assert.deepEqual(result, {type: 'out_invoice'}); + }); + + QUnit.test('return-input-value', function (assert) { + assert.expect(1); + + var result = pyEval.eval('contexts', [{ + __ref: 'compound_context', + __contexts: ["{'line_id': line_id , 'journal_id': journal_id }"], + __eval_context: { + __ref: 'compound_context', + __contexts: [{ + __ref: 'compound_context', + __contexts: [ + {lang: 'en_US', tz: 'Europe/Paris', uid: 1}, + {lang: 'en_US', tz: 'Europe/Paris', uid: 1}, + {} + ], + __eval_context: null, + }, { + active_id: false, + active_ids: [], + active_model: 'account.move', + amount: 0, + company_id: 1, + id: false, + journal_id: 14, + line_id: [ + [0, false, { + account_id: 55, + amount_currency: 0, + analytic_account_id: false, + credit: 0, + currency_id: false, + date_maturity: false, + debit: 0, + name: "dscsd", + partner_id: false, + tax_line_id: false, + }] + ], + name: '/', + narration: false, + parent: {}, + partner_id: false, + date: '2011-01-1', + ref: false, + state: 'draft', + to_check: false, + }], + __eval_context: null, + }, + }]); + assert.deepEqual(result, { + journal_id: 14, + line_id: [[0, false, { + account_id: 55, + amount_currency: 0, + analytic_account_id: false, + credit: 0, + currency_id: false, + date_maturity: false, + debit: 0, + name: "dscsd", + partner_id: false, + tax_line_id: false, + }]], + }); + }); + + QUnit.module('pyeval (domains)'); + + QUnit.test('current_date', function (assert) { + assert.expect(1); + + var current_date = time.date_to_str(new Date()); + var result = pyEval.eval('domains', + [[],{"__ref":"domain","__debug":"[('name','>=',current_date),('name','<=',current_date)]","__id":"5dedcfc96648"}], + pyEval.context()); + assert.deepEqual(result, [ + ['name', '>=', current_date], + ['name', '<=', current_date] + ]); + }); + + QUnit.test('context_freevar', function (assert) { + assert.expect(3); + + var domains_to_eval = [{ + __ref: 'domain', + __debug: '[("foo", "=", context.get("bar", "qux"))]' + }, [['bar', '>=', 42]]]; + assert.deepEqual( + pyEval.eval('domains', domains_to_eval, {bar: "ok"}), + [['foo', '=', 'ok'], ['bar', '>=', 42]]); + assert.deepEqual( + pyEval.eval('domains', domains_to_eval, {bar: false}), + [['foo', '=', false], ['bar', '>=', 42]]); + assert.deepEqual( + pyEval.eval('domains', domains_to_eval), + [['foo', '=', 'qux'], ['bar', '>=', 42]]); + }); + + QUnit.module('pyeval (groupbys)'); + + QUnit.test('groupbys_00', function (assert) { + assert.expect(1); + + var result = pyEval.eval('groupbys', [ + {group_by: 'foo'}, + {group_by: ['bar', 'qux']}, + {group_by: null}, + {group_by: 'grault'} + ]); + assert.deepEqual(result, ['foo', 'bar', 'qux', 'grault']); + }); + + QUnit.test('groupbys_01', function (assert) { + assert.expect(1); + + var result = pyEval.eval('groupbys', [ + {group_by: 'foo'}, + { __ref: 'context', __debug: '{"group_by": "bar"}' }, + {group_by: 'grault'} + ]); + assert.deepEqual(result, ['foo', 'bar', 'grault']); + }); + + QUnit.test('groupbys_02', function (assert) { + assert.expect(1); + + var result = pyEval.eval('groupbys', [ + {group_by: 'foo'}, + { + __ref: 'compound_context', + __contexts: [ {group_by: 'bar'} ], + __eval_context: null + }, + {group_by: 'grault'} + ]); + assert.deepEqual(result, ['foo', 'bar', 'grault']); + }); + + QUnit.test('groupbys_03', function (assert) { + assert.expect(1); + + var result = pyEval.eval('groupbys', [ + {group_by: 'foo'}, + { + __ref: 'compound_context', + __contexts: [ + { __ref: 'context', __debug: '{"group_by": value}' } + ], + __eval_context: { value: 'bar' } + }, + {group_by: 'grault'} + ]); + assert.deepEqual(result, ['foo', 'bar', 'grault']); + }); + + QUnit.test('groupbys_04', function (assert) { + assert.expect(1); + + var result = pyEval.eval('groupbys', [ + {group_by: 'foo'}, + { + __ref: 'compound_context', + __contexts: [ + { __ref: 'context', __debug: '{"group_by": value}' } + ], + __eval_context: { value: 'bar' } + }, + {group_by: 'grault'} + ], { value: 'bar' }); + assert.deepEqual(result, ['foo', 'bar', 'grault']); + }); + + QUnit.test('groupbys_05', function (assert) { + assert.expect(1); + + var result = pyEval.eval('groupbys', [ + {group_by: 'foo'}, + { __ref: 'context', __debug: '{"group_by": value}' }, + {group_by: 'grault'} + ], { value: 'bar' }); + assert.deepEqual(result, ['foo', 'bar', 'grault']); + }); + +}); + +}); diff --git a/addons/web/static/tests/core/registry_tests.js b/addons/web/static/tests/core/registry_tests.js new file mode 100644 index 00000000000..d28e429da24 --- /dev/null +++ b/addons/web/static/tests/core/registry_tests.js @@ -0,0 +1,83 @@ +odoo.define('web.registry_tests', function (require) { +"use strict"; + +var Registry = require('web.Registry'); + +QUnit.module('core', {}, function () { + + QUnit.module('Registry'); + + QUnit.test('key set', function (assert) { + assert.expect(1); + + var registry = new Registry(); + var foo = {}; + + registry + .add('foo', foo); + + assert.strictEqual(registry.get('foo'), foo); + }); + + QUnit.test('extension', function (assert) { + assert.expect(2); + + var foo = {}; + var foo2 = {}; + var registry = new Registry({ + foo: foo, + }); + var registry2 = registry.extend({foo: foo2}); + assert.strictEqual(registry.get('foo'), foo); + assert.strictEqual(registry2.get('foo'), foo2); + }); + + QUnit.test('remain-linked', function (assert) { + assert.expect(2); + + var foo = {}; + var foo2 = {}; + var registry = new Registry({ + foo: foo, + }); + + var registry2 = registry.extend(); + + registry.add('foo2', foo2); + + assert.strictEqual(registry.get('foo2'), foo2); + assert.strictEqual(registry2.get('foo2'), foo2); + }); + + QUnit.test('multiget', function (assert) { + assert.expect(1); + + var foo = {}; + var bar = {}; + var registry = new Registry({ + foo: foo, + bar: bar, + }); + assert.strictEqual( + registry.getAny(['qux', 'grault', 'bar', 'foo']), + bar, + "Registry getAny should find first defined key"); + }); + + QUnit.test('extended-multiget', function (assert) { + assert.expect(1); + + var foo = {}; + var bar = {}; + var registry = new Registry({ + foo: foo, + bar: bar, + }); + var registry2 = registry.extend(); + assert.strictEqual(registry2.getAny(['qux', 'grault', 'bar', 'foo']), bar); + }); + +}); + +}); + diff --git a/addons/web/static/tests/core/rpc_tests.js b/addons/web/static/tests/core/rpc_tests.js new file mode 100644 index 00000000000..c19f1007417 --- /dev/null +++ b/addons/web/static/tests/core/rpc_tests.js @@ -0,0 +1,217 @@ +odoo.define('web.rpc_tests', function (require) { +"use strict"; + +var rpc = require('web.rpc'); + +QUnit.module('core', {}, function () { + + QUnit.module('RPC Builder'); + + QUnit.test('basic rpc (route)', function (assert) { + assert.expect(1); + + var query = rpc.buildQuery({ + route: '/my/route', + }); + assert.strictEqual(query.route, '/my/route', "should have the proper route"); + }); + + QUnit.test('rpc on route with parameters', function (assert) { + assert.expect(1); + + var query = rpc.buildQuery({ + route: '/my/route', + params: {hey: 'there', model: 'test'}, + }); + + assert.deepEqual(query.params, {hey: 'there', model: 'test'}, + "should transfer the proper parameters"); + }); + + QUnit.test('basic rpc, with no context', function (assert) { + assert.expect(1); + + var query = rpc.buildQuery({ + model: 'partner', + method: 'test', + kwargs: {}, + }); + assert.notOk(query.params.kwargs.context, + "does not automatically add a context"); + }); + + QUnit.test('basic rpc, with context', function (assert) { + assert.expect(1); + + var query = rpc.buildQuery({ + model: 'partner', + method: 'test', + context: {a: 1}, + }); + + assert.deepEqual(query.params.kwargs.context, {a: 1}, + "properly transfer the context"); + }); + + QUnit.test('basic rpc, with context, part 2', function (assert) { + assert.expect(1); + + var query = rpc.buildQuery({ + model: 'partner', + method: 'test', + kwargs: {context: {a: 1}}, + }); + + assert.deepEqual(query.params.kwargs.context, {a: 1}, + "properly transfer the context"); + + }); + + QUnit.test('basic rpc (method of model)', function (assert) { + assert.expect(3); + + var query = rpc.buildQuery({ + model: 'partner', + method: 'test', + kwargs: {context: {a: 1}}, + }); + + assert.strictEqual(query.route, '/web/dataset/call_kw/partner/test', + "should call the proper route"); + assert.strictEqual(query.params.model, 'partner', + "should correctly specify the model"); + assert.strictEqual(query.params.method, 'test', + "should correctly specify the method"); + }); + + QUnit.test('rpc with args and kwargs', function (assert) { + assert.expect(4); + var query = rpc.buildQuery({ + model: 'partner', + method: 'test', + args: ['arg1', 2], + kwargs: {k: 78}, + }); + + assert.strictEqual(query.route, '/web/dataset/call_kw/partner/test', + "should call the proper route"); + assert.strictEqual(query.params.args[0], 'arg1', + "should call with correct args"); + assert.strictEqual(query.params.args[1], 2, + "should call with correct args"); + assert.strictEqual(query.params.kwargs.k, 78, + "should call with correct kargs"); + }); + + QUnit.test('search_read controller', function (assert) { + assert.expect(1); + var query = rpc.buildQuery({ + route: '/web/dataset/search_read', + model: 'partner', + domain: ['a', '=', 1], + fields: ['name'], + limit: 32, + offset: 2, + orderBy: [{name: 'yop', asc: true}, {name: 'aa', asc: false}], + }); + assert.deepEqual(query.params, { + context: {}, + domain: ['a', '=', 1], + fields: ['name'], + limit: 32, + offset: 2, + model: 'partner', + sort: 'yop ASC, aa DESC', + }, "should have correct args"); + }); + + QUnit.test('search_read method', function (assert) { + assert.expect(1); + var query = rpc.buildQuery({ + model: 'partner', + method: 'search_read', + domain: ['a', '=', 1], + fields: ['name'], + limit: 32, + offset: 2, + orderBy: [{name: 'yop', asc: true}, {name: 'aa', asc: false}], + }); + assert.deepEqual(query.params, { + args: [['a', '=', 1], ['name'], 2, 32, 'yop ASC, aa DESC'], + kwargs: {}, + method: 'search_read', + model: 'partner' + }, "should have correct args"); + }); + + QUnit.test('read_group', function (assert) { + assert.expect(2); + + var query = rpc.buildQuery({ + model: 'partner', + method: 'read_group', + domain: ['a', '=', 1], + fields: ['name'], + groupBy: ['product_id'], + context: {abc: 'def'}, + lazy: true, + }); + + assert.deepEqual(query.params, { + args: [], + kwargs: { + context: {abc: 'def'}, + domain: ['a', '=', 1], + fields: ['name'], + groupby: ['product_id'], + lazy: true, + orderby: false, + }, + method: 'read_group', + model: 'partner', + }, "should have correct args"); + assert.equal(query.route, '/web/dataset/call_kw/partner/read_group', + "should call correct route"); + }); + + QUnit.test('read_group with kwargs', function (assert) { + assert.expect(1); + + var query = rpc.buildQuery({ + model: 'partner', + method: 'read_group', + domain: ['a', '=', 1], + fields: ['name'], + groupBy: ['product_id'], + lazy: false, + kwargs: {context: {abc: 'def'}} + }); + + assert.deepEqual(query.params, { + args: [], + kwargs: { + context: {abc: 'def'}, + domain: ['a', '=', 1], + fields: ['name'], + groupby: ['product_id'], + lazy: false, + orderby: false, + }, + method: 'read_group', + model: 'partner', + }, "should have correct args"); + }); + + QUnit.test('search_read with no domain, nor fields', function (assert) { + assert.expect(2); + var query = rpc.buildQuery({ + model: 'partner', + route: '/web/dataset/search_read', + }); + + assert.deepEqual(query.params.domain, [], "should have [] as default domain"); + assert.strictEqual(query.params.fields, false, "should have false as default fields"); + }); +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/core/time_tests.js b/addons/web/static/tests/core/time_tests.js new file mode 100644 index 00000000000..3407f5f4276 --- /dev/null +++ b/addons/web/static/tests/core/time_tests.js @@ -0,0 +1,150 @@ +odoo.define('web.time_tests', function (require) { +"use strict"; + +var time = require('web.time'); + +QUnit.module('core', {}, function () { + + QUnit.module('Time utils'); + + QUnit.test('Parse server datetime', function (assert) { + assert.expect(4); + + var date = time.str_to_datetime("2009-05-04 12:34:23"); + assert.deepEqual( + [date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), + date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds()], + [2009, 5 - 1, 4, 12, 34, 23]); + assert.deepEqual( + [date.getFullYear(), date.getMonth(), date.getDate(), + date.getHours(), date.getMinutes(), date.getSeconds()], + [2009, 5 - 1, 4, 12 - (date.getTimezoneOffset() / 60), 34, 23]); + + var date2 = time.str_to_datetime('2011-12-10 00:00:00'); + assert.deepEqual( + [date2.getUTCFullYear(), date2.getUTCMonth(), date2.getUTCDate(), + date2.getUTCHours(), date2.getUTCMinutes(), date2.getUTCSeconds()], + [2011, 12 - 1, 10, 0, 0, 0]); + + var date3 = time.str_to_datetime("2009-05-04 12:34:23.84565"); + assert.deepEqual( + [date3.getUTCFullYear(), date3.getUTCMonth(), date3.getUTCDate(), + date3.getUTCHours(), date3.getUTCMinutes(), date3.getUTCSeconds(), date3.getUTCMilliseconds()], + [2009, 5 - 1, 4, 12, 34, 23, 845]); + }); + + QUnit.test('Parse server datetime on 31', function (assert) { + assert.expect(1); + + var wDate = window.Date; + + try { + window.Date = function (v) { + if (_.isUndefined(v)) { + v = '2013-10-31 12:34:56'; + } + return new wDate(v); + }; + var date = time.str_to_datetime('2013-11-11 02:45:21'); + + assert.deepEqual( + [date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), + date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds()], + [2013, 11 - 1, 11, 2, 45, 21]); + } + finally { + window.Date = wDate; + } + }); + + QUnit.test('Parse server date', function (assert) { + assert.expect(1); + + var date = time.str_to_date("2009-05-04"); + assert.deepEqual( + [date.getFullYear(), date.getMonth(), date.getDate()], + [2009, 5 - 1, 4]); + }); + + QUnit.test('Parse server date on 31', function (assert) { + assert.expect(1); + + var wDate = window.Date; + + try { + window.Date = function (v) { + if (_.isUndefined(v)) { + v = '2013-10-31 12:34:56'; + } + return new wDate(v); + }; + var date = time.str_to_date('2013-11-21'); + + assert.deepEqual( + [date.getFullYear(), date.getMonth(), date.getDate()], + [2013, 11 - 1, 21]); + } + finally { + window.Date = wDate; + } + }); + + QUnit.test('Parse server time', function (assert) { + assert.expect(2); + + var date = time.str_to_time("12:34:23"); + assert.deepEqual( + [date.getHours(), date.getMinutes(), date.getSeconds()], + [12, 34, 23]); + + date = time.str_to_time("12:34:23.5467"); + assert.deepEqual( + [date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()], + [12, 34, 23, 546]); + }); + + QUnit.test('Format server datetime', function (assert) { + assert.expect(1); + + var date = new Date(); + date.setUTCFullYear(2009); + date.setUTCMonth(5 - 1); + date.setUTCDate(4); + date.setUTCHours(12); + date.setUTCMinutes(34); + date.setUTCSeconds(23); + assert.strictEqual(time.datetime_to_str(date), "2009-05-04 12:34:23"); + }); + + QUnit.test('Format server date', function (assert) { + assert.expect(1); + + var date = new Date(); + date.setUTCFullYear(2009); + date.setUTCMonth(5 - 1); + date.setUTCDate(4); + date.setUTCHours(0); + date.setUTCMinutes(0); + date.setUTCSeconds(0); + assert.strictEqual(time.date_to_str(date), "2009-05-04"); + }); + + QUnit.test('Format server time', function (assert) { + assert.expect(1); + + var date = new Date(); + date.setUTCFullYear(1970); + date.setUTCMonth(1 - 1); + date.setUTCDate(1); + date.setUTCHours(0); + date.setUTCMinutes(0); + date.setUTCSeconds(0); + date.setHours(12); + date.setMinutes(34); + date.setSeconds(23); + assert.strictEqual(time.time_to_str(date), "12:34:23"); + }); + +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/core/util_tests.js b/addons/web/static/tests/core/util_tests.js new file mode 100644 index 00000000000..204503117e6 --- /dev/null +++ b/addons/web/static/tests/core/util_tests.js @@ -0,0 +1,49 @@ +odoo.define('web.util_tests', function (require) { +"use strict"; + +var utils = require('web.utils'); + +QUnit.module('core', {}, function () { + + QUnit.module('utils'); + + + QUnit.test('intersperse', function (assert) { + assert.expect(27); + + var intersperse = utils.intersperse; + + assert.strictEqual(intersperse("", []), ""); + assert.strictEqual(intersperse("0", []), "0"); + assert.strictEqual(intersperse("012", []), "012"); + assert.strictEqual(intersperse("1", []), "1"); + assert.strictEqual(intersperse("12", []), "12"); + assert.strictEqual(intersperse("123", []), "123"); + assert.strictEqual(intersperse("1234", []), "1234"); + assert.strictEqual(intersperse("123456789", []), "123456789"); + assert.strictEqual(intersperse("&ab%#@1", []), "&ab%#@1"); + + assert.strictEqual(intersperse("0", []), "0"); + assert.strictEqual(intersperse("0", [1]), "0"); + assert.strictEqual(intersperse("0", [2]), "0"); + assert.strictEqual(intersperse("0", [200]), "0"); + + assert.strictEqual(intersperse("12345678", [0], '.'), '12345678'); + assert.strictEqual(intersperse("", [1], '.'), ''); + assert.strictEqual(intersperse("12345678", [1], '.'), '1234567.8'); + assert.strictEqual(intersperse("12345678", [1], '.'), '1234567.8'); + assert.strictEqual(intersperse("12345678", [2], '.'), '123456.78'); + assert.strictEqual(intersperse("12345678", [2, 1], '.'), '12345.6.78'); + assert.strictEqual(intersperse("12345678", [2, 0], '.'), '12.34.56.78'); + assert.strictEqual(intersperse("12345678", [-1, 2], '.'), '12345678'); + assert.strictEqual(intersperse("12345678", [2, -1], '.'), '123456.78'); + assert.strictEqual(intersperse("12345678", [2, 0, 1], '.'), '12.34.56.78'); + assert.strictEqual(intersperse("12345678", [2, 0, 0], '.'), '12.34.56.78'); + assert.strictEqual(intersperse("12345678", [2, 0, -1], '.'), '12.34.56.78'); + assert.strictEqual(intersperse("12345678", [3,3,3,3], '.'), '12.345.678'); + assert.strictEqual(intersperse("12345678", [3,0], '.'), '12.345.678'); + }); + +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/core/widget_tests.js b/addons/web/static/tests/core/widget_tests.js new file mode 100644 index 00000000000..8988002734b --- /dev/null +++ b/addons/web/static/tests/core/widget_tests.js @@ -0,0 +1,395 @@ +odoo.define('web.widget_tests', function (require) { +"use strict"; + +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var QWeb = require('web.QWeb'); +var Widget = require('web.Widget'); + +QUnit.module('core', {}, function () { + + QUnit.module('Widget'); + + QUnit.test('proxy (String)', function (assert) { + assert.expect(1); + + var W = Widget.extend({ + exec: function () { + this.executed = true; + } + }); + var w = new W(); + var fn = w.proxy('exec'); + fn(); + assert.ok(w.executed, 'should execute the named method in the right context'); + }); + + QUnit.test('proxy (String)(*args)', function (assert) { + assert.expect(2); + + var W = Widget.extend({ + exec: function (arg) { + this.executed = arg; + } + }); + var w = new W(); + var fn = w.proxy('exec'); + fn(42); + assert.ok(w.executed, "should execute the named method in the right context"); + assert.strictEqual(w.executed, 42, "should be passed the proxy's arguments"); + }); + + QUnit.test('proxy (String), include', function (assert) { + assert.expect(1); + + // the proxy function should handle methods being changed on the class + // and should always proxy "by name", to the most recent one + var W = Widget.extend({ + exec: function () { + this.executed = 1; + } + }); + var w = new W(); + var fn = w.proxy('exec'); + W.include({ + exec: function () { this.executed = 2; } + }); + + fn(); + assert.strictEqual(w.executed, 2, "should be lazily resolved"); + }); + + QUnit.test('proxy (Function)', function (assert) { + assert.expect(1); + + var w = new (Widget.extend({ }))(); + + var fn = w.proxy(function () { this.executed = true; }); + fn(); + assert.ok(w.executed, "should set the function's context (like Function#bind)"); + }); + + QUnit.test('proxy (Function)(*args)', function (assert) { + assert.expect(1); + + var w = new (Widget.extend({ }))(); + + var fn = w.proxy(function (arg) { this.executed = arg; }); + fn(42); + assert.strictEqual(w.executed, 42, "should be passed the proxy's arguments"); + }); + + + + QUnit.test('renderElement, no template, default', function (assert) { + assert.expect(8); + + var widget = new (Widget.extend({ }))(); + + var $original = widget.$el; + assert.ok($original, "should initially have a root element"); + + widget.renderElement(); + + assert.ok(widget.$el, "should have generated a root element"); + assert.ok($original !== widget.$el, "should have generated a new root element"); + assert.strictEqual(widget.$el, widget.$el, "should provide $el alias"); + assert.ok(widget.$el.is(widget.el), "should provide raw DOM alias"); + + assert.strictEqual(widget.el.nodeName, 'DIV', "should have generated the default element"); + assert.strictEqual(widget.el.attributes.length, 0, "should not have generated any attribute"); + assert.ok(_.isEmpty(widget.$el.html(), "should not have generated any content")); + }); + + QUnit.test('no template, custom tag', function (assert) { + assert.expect(1); + + + var widget = new (Widget.extend({ + tagName: 'ul' + }))(); + widget.renderElement(); + + assert.strictEqual(widget.el.nodeName, 'UL', "should have generated the custom element tag"); + }); + + QUnit.test('no template, @id', function (assert) { + assert.expect(3); + + var widget = new (Widget.extend({ + id: 'foo' + }))(); + widget.renderElement(); + + assert.strictEqual(widget.el.attributes.length, 1, "should have one attribute"); + assert.strictEqual(widget.$el.attr('id'), 'foo', "should have generated the id attribute"); + assert.strictEqual(widget.el.id, 'foo', "should also be available via property"); + }); + + QUnit.test('no template, @className', function (assert) { + assert.expect(2); + + var widget = new (Widget.extend({ + className: 'oe_some_class' + }))(); + widget.renderElement(); + + assert.strictEqual(widget.el.className, 'oe_some_class', "should have the right property"); + assert.strictEqual(widget.$el.attr('class'), 'oe_some_class', "should have the right attribute"); + }); + + QUnit.test('no template, bunch of attributes', function (assert) { + assert.expect(9); + + var widget = new (Widget.extend({ + attributes: { + 'id': 'some_id', + 'class': 'some_class', + 'data-foo': 'data attribute', + 'clark': 'gable', + 'spoiler': // don't read the next line if you care about Harry Potter... + 'snape kills dumbledore' + } + }))(); + widget.renderElement(); + + assert.strictEqual(widget.el.attributes.length, 5, "should have all the specified attributes"); + + assert.strictEqual(widget.el.id, 'some_id'); + assert.strictEqual(widget.$el.attr('id'), 'some_id'); + + assert.strictEqual(widget.el.className, 'some_class'); + assert.strictEqual(widget.$el.attr('class'), 'some_class'); + + assert.strictEqual(widget.$el.attr('data-foo'), 'data attribute'); + assert.strictEqual(widget.$el.data('foo'), 'data attribute'); + + assert.strictEqual(widget.$el.attr('clark'), 'gable'); + assert.strictEqual(widget.$el.attr('spoiler'), 'snape kills dumbledore'); + }); + + QUnit.test('template', function (assert) { + assert.expect(3); + + core.qweb.add_template( + '<no>' + + '<t t-name="test.widget.template">' + + '<ol>' + + '<li t-foreach="5" t-as="counter" ' + + 't-attf-class="class-#{counter}">' + + '<input/>' + + '<t t-esc="counter"/>' + + '</li>' + + '</ol>' + + '</t>' + + '</no>' + ); + + var widget = new (Widget.extend({ + template: 'test.widget.template' + }))(); + widget.renderElement(); + + assert.strictEqual(widget.el.nodeName, 'OL'); + assert.strictEqual(widget.$el.children().length, 5); + assert.strictEqual(widget.el.textContent, '01234'); + }); + + QUnit.test('repeated', function (assert) { + assert.expect(4); + var $fix = $( "#qunit-fixture"); + + core.qweb.add_template( + '<no>' + + '<t t-name="test.widget.template">' + + '<p><t t-esc="widget.value"/></p>' + + '</t>' + + '</no>' + ); + var widget = new (Widget.extend({ + template: 'test.widget.template' + }))(); + widget.value = 42; + + return widget.appendTo($fix) + .done(function () { + assert.strictEqual($fix.find('p').text(), '42', "DOM fixture should contain initial value"); + assert.strictEqual(widget.$el.text(), '42', "should set initial value"); + widget.value = 36; + widget.renderElement(); + assert.strictEqual($fix.find('p').text(), '36', "DOM fixture should use new value"); + assert.strictEqual(widget.$el.text(), '36', "should set new value"); + }); + }); + + + QUnit.module('Widgets, with QWeb', { + beforeEach: function() { + this.oldQWeb = core.qweb; + core.qweb = new QWeb(); + core.qweb.add_template( + '<no>' + + '<t t-name="test.widget.template">' + + '<ol>' + + '<li t-foreach="5" t-as="counter" ' + + 't-attf-class="class-#{counter}">' + + '<input/>' + + '<t t-esc="counter"/>' + + '</li>' + + '</ol>' + + '</t>' + + '</no>' + ); + }, + afterEach: function() { + core.qweb = this.oldQWeb; + }, + }); + + QUnit.test('basic-alias', function (assert) { + assert.expect(1); + + + var widget = new (Widget.extend({ + template: 'test.widget.template' + }))(); + widget.renderElement(); + + assert.ok(widget.$('li:eq(3)').is(widget.$el.find('li:eq(3)')), + "should do the same thing as calling find on the widget root"); + }); + + + QUnit.test('delegate', function (assert) { + assert.expect(5); + + var a = []; + var widget = new (Widget.extend({ + template: 'test.widget.template', + events: { + 'click': function () { + a[0] = true; + assert.strictEqual(this, widget, "should trigger events in widget"); + }, + 'click li.class-3': 'class3', + 'change input': function () { a[2] = true; } + }, + class3: function () { a[1] = true; } + }))(); + widget.renderElement(); + + widget.$el.click(); + widget.$('li:eq(3)').click(); + widget.$('input:last').val('foo').change(); + + for(var i=0; i<3; ++i) { + assert.ok(a[i], "should pass test " + i); + } + }); + + QUnit.test('undelegate', function (assert) { + assert.expect(4); + + var clicked = false; + var newclicked = false; + + var widget = new (Widget.extend({ + template: 'test.widget.template', + events: { 'click li': function () { clicked = true; } } + }))(); + + widget.renderElement(); + widget.$el.on('click', 'li', function () { newclicked = true; }); + + widget.$('li').click(); + assert.ok(clicked, "should trigger bound events"); + assert.ok(newclicked, "should trigger bound events"); + + clicked = newclicked = false; + widget.undelegateEvents(); + widget.$('li').click(); + assert.ok(!clicked, "undelegate should unbind events delegated"); + assert.ok(newclicked, "undelegate should only unbind events it created"); + }); + + QUnit.module('Widget, and async stuff'); + + QUnit.test("alive(alive)", function (assert) { + assert.expect(1); + + var widget = new (Widget.extend({})); + + return concurrency.asyncWhen(widget.start()) + .then(function () { return widget.alive(concurrency.asyncWhen()); }) + .then(function () { assert.ok(true); }); + }); + + QUnit.test("alive(dead)", function (assert) { + assert.expect(1); + var widget = new (Widget.extend({})); + + return $.Deferred(function (d) { + concurrency.asyncWhen(widget.start()) + .then(function () { + // destroy widget + widget.destroy(); + var promise = concurrency.asyncWhen(); + // leave time for alive() to do its stuff + promise.then(function () { + return concurrency.asyncWhen(); + }).then(function () { + assert.ok(true); + d.resolve(); + }); + // ensure that widget.alive() refuses to resolve or reject + return widget.alive(promise); + }).always(function () { + d.reject(); + assert.ok(false, "alive() should not terminate by default"); + }); + }); + }); + + QUnit.test("alive(alive, true)", function (assert) { + assert.expect(1); + var widget = new (Widget.extend({})); + return concurrency.asyncWhen(widget.start()) + .then(function () { return widget.alive(concurrency.asyncWhen(), true) }) + .then(function () { assert.ok(true); }); + }); + + QUnit.test("alive(dead, true)", function (assert) { + assert.expect(1); + var done = assert.async(); + + var widget = new (Widget.extend({})); + + concurrency.asyncWhen(widget.start()) + .then(function () { + // destroy widget + widget.destroy(); + return widget.alive(concurrency.asyncWhen(), true); + }).then(function () { + assert.ok(false, "alive(p, true) should fail its promise"); + done(); + }, function () { + assert.ok(true, "alive(p, true) should fail its promise"); + done(); + }); + }); + + QUnit.test("calling _rpc on destroyed widgets", function (assert) { + assert.expect(1); + + var widget = new Widget(); + widget.destroy(); + widget._rpc({route: '/a/route'}).always(function () { + throw Error("Calling _rpc on a destroyed widget should return a " + + "deferred that is never resolved nor rejected"); + }); + assert.ok(true, + "there should be no crash when calling _rpc on a destroyed widget"); + }); +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js new file mode 100644 index 00000000000..c56f694e1fe --- /dev/null +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -0,0 +1,2718 @@ +odoo.define('web.basic_fields_tests', function (require) { +"use strict"; + +var concurrency = require('web.concurrency'); +var FormView = require('web.FormView'); +var KanbanView = require('web.KanbanView'); +var ListView = require('web.ListView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +QUnit.module('fields', {}, function () { + +QUnit.module('basic_fields', { + beforeEach: function () { + this.data = { + partner: { + fields: { + date: {string: "A date", type: "date", searchable: true}, + datetime: {string: "A datetime", type: "datetime", searchable: true}, + display_name: {string: "Displayed name", type: "char", searchable: true}, + foo: {string: "Foo", type: "char", default: "My little Foo Value", searchable: true}, + bar: {string: "Bar", type: "boolean", default: true, searchable: true}, + int_field: {string: "int_field", type: "integer", sortable: true, searchable: true}, + qux: {string: "Qux", type: "float", digits: [16,1], searchable: true}, + p: {string: "one2many field", type: "one2many", relation: 'partner', searchable: true}, + trululu: {string: "Trululu", type: "many2one", relation: 'partner', searchable: true}, + timmy: {string: "pokemon", type: "many2many", relation: 'partner_type', searchable: true}, + product_id: {string: "Product", type: "many2one", relation: 'product', searchable: true}, + sequence: {type: "integer", string: "Sequence", searchable: true}, + currency_id: {string: "Currency", type: "many2one", relation: "currency", searchable: true}, + selection: {string: "Selection", type: "selection", searchable:true, + selection: [['normal', 'Normal'],['blocked', 'Blocked'],['done', 'Done']]}, + }, + records: [{ + id: 1, + date: "2017-02-03", + datetime: "2017-02-08 10:00:00", + display_name: "first record", + bar: true, + foo: "yop", + int_field: 10, + qux: 0.44444, + p: [], + timmy: [], + trululu: 4, + selection: 'blocked', + }, { + id: 2, + display_name: "second record", + bar: true, + foo: "blip", + int_field: 0, + qux: 0, + p: [], + timmy: [], + trululu: 1, + sequence: 4, + currency_id: 2, + selection: 'normal', + }, { + id: 4, + display_name: "aaa", + foo: "abc", + sequence: 9, + int_field: false, + qux: false, + selection: 'done', + }, + {id: 3, bar: true, foo: "gnap", int_field: 80, qux: -3.89859, m2o: 1, m2m: []}, + {id: 5, bar: false, foo: "blop", int_field: -4, qux: 9.1, m2o: 1, m2m: [1], currency_id: 1}], + onchanges: {}, + }, + product: { + fields: { + name: {string: "Product Name", type: "char", searchable: true} + }, + records: [{ + id: 37, + display_name: "xphone", + }, { + id: 41, + display_name: "xpad", + }] + }, + partner_type: { + fields: { + name: {string: "Partner Type", type: "char", searchable: true}, + color: {string: "Color index", type: "integer", searchable: true}, + }, + records: [ + {id: 12, display_name: "gold", color: 2}, + {id: 14, display_name: "silver", color: 5}, + ] + }, + currency: { + fields: { + symbol: {string: "Currency Sumbol", type: "char", searchable: true}, + position: {string: "Currency Position", type: "char", searchable: true}, + }, + records: [{ + id: 1, + display_name: "$", + symbol: "$", + position: "before", + }, { + id: 2, + display_name: "€", + symbol: "€", + position: "after", + }] + }, + }; + } +}, function () { + + QUnit.module('FieldBoolean'); + + QUnit.test('boolean field in form view', function (assert) { + assert.expect(7); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="bar"/></form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_field_boolean input:checked').length, 1, + "checkbox should be checked"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_field_boolean input:checked').length, 1, + "checkbox should still be checked"); + + // uncheck the checkbox + form.$('.o_field_boolean input:checked').click(); + assert.strictEqual(form.$('.o_field_boolean input:checked').length, 0, + "checkbox should no longer be checked"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_field_boolean input:checked').length, 0, + "checkbox should still no longer be checked"); + + // switch to edit mode and test the opposite change + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_field_boolean input:checked').length, 0, + "checkbox should still be unchecked"); + // check the checkbox by hitting the "enter" key after focusing it + form.$('.o_field_boolean input') + .trigger("focusin") + .trigger({type: "keydown", which: $.ui.keyCode.ENTER}) + .trigger({type: "keyup", which: $.ui.keyCode.ENTER}); + assert.strictEqual(form.$('.o_field_boolean input:checked').length, 1, + "checkbox should now be checked"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_field_boolean input:checked').length, 1, + "checkbox should still be checked"); + form.destroy(); + }); + + QUnit.test('boolean field in editable list view', function (assert) { + assert.expect(11); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="bar"/></tree>', + }); + + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input').length, 5, + "should have 5 checkboxes"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input:checked').length, 4, + "should have 4 checked input"); + + // Edit a line + var $cell = list.$('tr.o_data_row:has(.o_checkbox input:checked) td:not(.o_list_record_selector)').first(); + assert.ok($cell.find('.o_checkbox input:checked').prop('disabled'), + "input should be disabled in readonly mode") + $cell.click(); + assert.ok(!$cell.find('.o_checkbox input:checked').prop('disabled'), + "input should not have the disabled property in edit mode") + $cell.find('.o_checkbox input:checked').click(); + + // save + list.$buttons.find('.o_list_button_save').click(); + $cell = list.$('tr.o_data_row:has(.o_checkbox input:not(:checked)) td:not(.o_list_record_selector)').first(); + assert.ok($cell.find('.o_checkbox input:not(:checked)').prop('disabled'), + "input should be disabled again") + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input').length, 5, + "should still have 5 checkboxes"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input:checked').length, 3, + "should now have only 3 checked input"); + + // Re-Edit the line and fake-check the checkbox + $cell.click(); + var $checkbox = $cell.find('.o_checkbox input:not(:checked)'); + $checkbox.click(); // Change the checkbox + $checkbox.click(); // Undo the change + + // Save + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input').length, 5, + "should still have 5 checkboxes"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input:checked').length, 3, + "should still have only 3 checked input"); + + // Re-Edit the line to check the checkbox back + $cell = list.$('tr.o_data_row:has(.o_checkbox input:not(:checked)) td:not(.o_list_record_selector)').first(); + $cell.click(); + $cell.find('.o_checkbox input:not(:checked)').click(); + + // save + list.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input').length, 5, + "should still have 5 checkboxes"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .o_checkbox input:checked').length, 4, + "should now have 4 checked input back"); + list.destroy(); + }); + + + QUnit.module('FieldBooleanButton'); + + QUnit.test('use custom terminology in form view', function (assert) { + assert.expect(2); + + var terminology = { + string_true: "Production Environment", + hover_true: "Switch to test environment", + string_false: "Test Environment", + hover_false: "Switch to production environment" + }; + var form = 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" widget="boolean_button" options=\'{"terminology": ' + + JSON.stringify(terminology) + '}\'/>' + + '</button>' + + '</div>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_stat_text.o_not_hover:contains(Production Environment)').length, 1, + "button should contain correct string"); + assert.strictEqual(form.$('.o_stat_text.o_hover:contains(Switch to test environment)').length, 1, + "button should display correct string when hovering"); + form.destroy(); + }); + + + QUnit.module('FieldFloat'); + + QUnit.test('float field when unset', function(assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="qux" digits="[5,3]"/>' + + '</sheet>' + + '</form>', + res_id: 4, + }); + + assert.ok(form.$('.o_form_field').hasClass('o_form_field_empty'), + 'Non-set float field should be recognized as unset.'); + + form.destroy(); + }); + + QUnit.test('float fields use correct digit precision', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="qux"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + assert.strictEqual(form.$('span.o_form_field_number:contains(0.4)').length, 1, + "should contain a number rounded to 1 decimal"); + form.destroy(); + }); + + QUnit.test('float field in form view', function(assert) { + assert.expect(5); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="qux" widget="float" digits="[5,3]"/>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.ok(!form.$('.o_form_field').hasClass('o_form_field_empty'), + 'Float field should be considered set for value 0.'); + assert.strictEqual(form.$('.o_form_field.o_form_field_number').first().text(), '0.000', + 'The value should be displayed properly.') + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input.o_form_input').val(), '0.000', + 'The value should be rendered with correct precision.') + + form.$('input.o_form_input').val('108.2458938598598').trigger('input'); + assert.strictEqual(form.$('input.o_form_input').val(), '108.2458938598598', + 'The value should not be formated yet.') + + form.$('input.o_form_input').val('18.8958938598598').trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field.o_form_field_number').first().text(), '18.896', + 'The new value should be rounded properly.') + + form.destroy(); + }); + + QUnit.test('float field in editable list view', function (assert) { + assert.expect(4); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="qux" widget="float" digits="[5,3]"/>' + + '</tree>', + }); + + var zeroValues = list.$('td').filter(function() {return $(this).text() === '0.000'}); + assert.strictEqual(zeroValues.length, 2, + 'Unset float values should be rendered as zeros.'); + + // switch to edit mode + var $cell = list.$('tr.o_data_row td:not(.o_list_record_selector)').first(); + $cell.click(); + + assert.strictEqual(list.$('input.o_form_input').length, 1, + 'The view should have 1 input for editable float.') + + list.$('input.o_form_input').val('108.2458938598598').trigger('input'); + assert.strictEqual(list.$('input.o_form_input').val(), '108.2458938598598', + 'The value should not be formated yet.') + + list.$('input.o_form_input').val('18.8958938598598').trigger('input'); + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('.o_form_field.o_form_field_number').first().text(), '18.896', + 'The new value should be rounded properly.') + + list.destroy(); + }); + + + QUnit.module('EmailWidget'); + + QUnit.test('email field in form view', function (assert) { + assert.expect(7); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" widget="email"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + var $mailtoLink = form.$('a.o_form_uri.o_form_field.o_text_overflow'); + assert.strictEqual($mailtoLink.length, 1, + "should have a anchor with correct classes"); + assert.strictEqual($mailtoLink.text(), 'yop', + "the value should be displayed properly"); + assert.strictEqual($mailtoLink.attr('href'), 'mailto:yop', + "should have proper mailto prefix"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').length, 1, + "should have an input for the email field"); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').val(), 'yop', + "input should contain field value in edit mode"); + + // change value in edit mode + form.$('input[type="text"].o_form_input.o_form_field').val('new').trigger('input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + $mailtoLink = form.$('a.o_form_uri.o_form_field.o_text_overflow'); + assert.strictEqual($mailtoLink.text(), 'new', + "new value should be displayed properly"); + assert.strictEqual($mailtoLink.attr('href'), 'mailto:new', + "should still have proper mailto prefix"); + + form.destroy(); + }); + + QUnit.test('email field in editable list view', function(assert) { + assert.expect(10); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="foo" widget="email"/></tree>', + }); + + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').length, 5, + "should have 5 cells"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'yop', + "value should be displayed properly as text"); + + var $mailtoLink = list.$('a.o_form_uri.o_field_widget.o_text_overflow'); + assert.strictEqual($mailtoLink.length, 5, + "should have anchors with correct classes"); + assert.strictEqual($mailtoLink.first().attr('href'), 'mailto:yop', + "should have proper mailto prefix"); + + // Edit a line and check the result + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + $cell.click(); + assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode'); + assert.strictEqual($cell.find('input').val(), 'yop', + 'should have the corect value in internal input'); + $cell.find('input').val('new').trigger('input');; + + // save + list.$buttons.find('.o_list_button_save').click(); + $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore'); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'new', + "value should be properly updated"); + $mailtoLink = list.$('a.o_form_uri.o_field_widget.o_text_overflow'); + assert.strictEqual($mailtoLink.length, 5, + "should still have anchors with correct classes"); + assert.strictEqual($mailtoLink.first().attr('href'), 'mailto:new', + "should still have proper mailto prefix"); + + list.destroy(); + }); + + + QUnit.module('FieldChar'); + + QUnit.test('char widget isValid method works', function (assert) { + assert.expect(1); + + this.data.partner.fields.foo.required = true; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + + var charField = form.renderer.widgets[0]; + assert.strictEqual(charField.isValid(), true); + form.destroy(); + }); + + QUnit.test('char field in form view', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_form_field').text(), 'yop', + "the value should be displayed properly"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').length, 1, + "should have an input for the char field"); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').val(), 'yop', + "input should contain field value in edit mode"); + + // change value in edit mode + form.$('input[type="text"].o_form_input.o_form_field').val('limbo').trigger('input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field').text(), 'limbo', + 'the new value should be displayed'); + form.destroy(); + }); + + QUnit.test('char field in editable list view', function(assert) { + assert.expect(6); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="foo"/></tree>', + }); + + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').length, 5, + "should have 5 cells"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'yop', + "value should be displayed properly as text"); + + // Edit a line and check the result + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + $cell.click(); + assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode'); + assert.strictEqual($cell.find('input').val(), 'yop', + 'should have the corect value in internal input'); + $cell.find('input').val('brolo').trigger('input');; + + // save + list.$buttons.find('.o_list_button_save').click(); + $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore'); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'brolo', + "value should be properly updated"); + list.destroy(); + }); + + + QUnit.module('UrlWidget'); + + QUnit.test('url widget in form view', function (assert) { + assert.expect(8); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" widget="url"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('a.o_form_uri.o_form_field.o_text_overflow').length, 1, + "should have a anchor with correct classes"); + assert.strictEqual(form.$('a.o_form_uri.o_form_field.o_text_overflow').attr('href'), 'yop', + "should have proper href link"); + assert.strictEqual(form.$('a.o_form_uri.o_form_field.o_text_overflow').text(), 'yop', + "the value should be displayed properly"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').length, 1, + "should have an input for the char field"); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').val(), 'yop', + "input should contain field value in edit mode"); + + // change value in edit mode + form.$('input[type="text"].o_form_input.o_form_field').val('limbo').trigger('input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('a.o_form_uri.o_form_field.o_text_overflow').length, 1, + "should still have a anchor with correct classes"); + assert.strictEqual(form.$('a.o_form_uri.o_form_field.o_text_overflow').attr('href'), 'limbo', + "should have proper new href link"); + assert.strictEqual(form.$('a.o_form_uri.o_form_field.o_text_overflow').text(), 'limbo', + 'the new value should be displayed'); + + form.destroy(); + }); + + QUnit.test('char field in editable list view', function(assert) { + assert.expect(10); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="foo" widget="url"/></tree>', + }); + + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').length, 5, + "should have 5 cells"); + assert.strictEqual(list.$('a.o_form_uri.o_field_widget.o_text_overflow').length, 5, + "should have 5 anchors with correct classes"); + assert.strictEqual(list.$('a.o_form_uri.o_field_widget.o_text_overflow').first().attr('href'), 'yop', + "should have proper href link"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'yop', + "value should be displayed properly as text"); + + // Edit a line and check the result + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + $cell.click(); + assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode'); + assert.strictEqual($cell.find('input').val(), 'yop', + 'should have the corect value in internal input'); + $cell.find('input').val('brolo').trigger('input');; + + // save + list.$buttons.find('.o_list_button_save').click(); + $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore'); + assert.strictEqual(list.$('a.o_form_uri.o_field_widget.o_text_overflow').length, 5, + "should still have 5 anchors with correct classes"); + assert.strictEqual(list.$('a.o_form_uri.o_field_widget.o_text_overflow').first().attr('href'), 'brolo', + "should have proper new href link"); + assert.strictEqual(list.$('a.o_form_uri.o_field_widget.o_text_overflow').first().text(), 'brolo', + "value should be properly updated"); + + list.destroy(); + }); + + + QUnit.module('FieldText'); + + QUnit.test('text fields are correctly rendered', function (assert) { + assert.expect(7); + + this.data.partner.fields.foo.type = 'text'; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + + assert.ok(form.$('div.o_form_textarea').length, "should have a text area"); + assert.strictEqual(form.$('div.o_form_textarea').text(), 'yop', 'should be "yop" in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + var $textarea = form.$('.o_form_textarea textarea'); + assert.ok($textarea.length, "should have a text area"); + assert.strictEqual($textarea.val(), 'yop', 'should still be "yop" in edit'); + + $textarea.val('hello').trigger('input'); + assert.strictEqual($textarea.val(), 'hello', 'should be "hello" after first edition'); + + $textarea.val('hello world').trigger('input'); + assert.strictEqual($textarea.val(), 'hello world', 'should be "hello world" after second edition'); + + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('div.o_form_textarea').text(), 'hello world', + 'should be "hello world" after save'); + form.destroy(); + }); + + QUnit.test('text fields in edit mode have correct height', function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.type = 'text'; + this.data.partner.records[0].foo = "f\nu\nc\nk\nm\ni\nl\ng\nr\no\nm"; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + + var $field = form.$('div.o_list_text'); + + assert.strictEqual($field.outerHeight(), $field[0].scrollHeight, + "text field should not have a scroll bar"); + + form.$buttons.find('.o_form_button_edit').click(); + + var $textarea = form.$('textarea:first'); + + // the difference is to take small calculation errors into account + assert.strictEqual($textarea.innerHeight(), $textarea[0].scrollHeight, + "textarea should not have a scroll bar"); + form.destroy(); + }); + + + QUnit.test('text field rendering in list view', function (assert) { + assert.expect(1); + + var data = { + foo: { + fields: {foo: {string: "F", type: "text"}}, + records: [{id: 1, foo: "some text"}] + }, + }; + var list = createView({ + View: ListView, + model: 'foo', + data: data, + arch: '<tree><field name="foo"/></tree>', + }); + + assert.strictEqual(list.$('tbody td.o_list_text:contains(some text)').length, 1, + "should have a td with the .o_list_text class"); + list.destroy(); + }); + + QUnit.test('field text in editable list view', function (assert) { + assert.expect(1); + + this.data.partner.fields.foo.type = 'text'; + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree string="Phonecalls" editable="top">' + + '<field name="foo"/>' + + '</tree>', + }); + + list.$buttons.find('.o_list_button_add').click(); + + assert.strictEqual(list.$('textarea').first().get(0), document.activeElement, + "text area should have the focus"); + list.destroy(); + }); + + QUnit.test('field changes are correctly debounced in text fields', function (assert) { + var done = assert.async(); + assert.expect(5); + + this.data.partner.fields.foo.type = 'text'; + + var def = $.Deferred(); + var nbNotifyChanges = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 2, + fieldDebounce: 3, + }); + + var _onFieldChanged = form._onFieldChanged; + form._onFieldChanged = function () { + _onFieldChanged.apply(form, arguments); + nbNotifyChanges++; + def.resolve(); + }; + + form.$buttons.find('.o_form_button_edit').click(); + + form.$('textarea').val("1").trigger('input'); + assert.strictEqual(nbNotifyChanges, 0, + "no event should have been triggered"); + form.$('textarea').val("12").trigger('input'); + assert.strictEqual(nbNotifyChanges, 0, + "no event should have been triggered"); + + return waitForChangeTriggered().then(function () { + assert.strictEqual(nbNotifyChanges, 1, + "one event should have been triggered"); + + // add something in the textarea, then focus another input + form.$('textarea').first().val("123").trigger('input'); + form.$('textarea').first().change(); + assert.strictEqual(nbNotifyChanges, 2, + "one event should have been triggered immediately"); + + return waitForChangeTriggered(); + }).then(function () { + assert.strictEqual(nbNotifyChanges, 2, + "no extra event should have been triggered"); + + form.destroy(); + done(); + }); + + function waitForChangeTriggered() { + return def.then(function () { + def = $.Deferred(); + return concurrency.delay(0); + }); + } + }); + + QUnit.module('JournalDashboardGraph'); + + QUnit.test('graph dashboard widget is rendered correctly', function (assert) { + assert.expect(2); + + _.extend(this.data.partner.fields, { + graph_data: { string: "Graph Data", type: "text" }, + graph_type: { + string: "Graph Type", + type: "selection", + selection: [['line', 'Line'], ['bar', 'Bar']] + }, + }); + this.data.partner.records[0].graph_type = "bar"; + this.data.partner.records[1].graph_type = "line"; + var graph_values = [ + {'value': 300, 'label': '5-11 Dec'}, + {'value': 500, 'label': '12-18 Dec'}, + {'value': 100, 'label': '19-25 Dec'}, + ]; + this.data.partner.records[0].graph_data = JSON.stringify([{ + color: 'red', + title: 'Partner 0', + values: graph_values, + key: 'A key', + area: true, + }]); + this.data.partner.records[1].graph_data = JSON.stringify([{ + color: 'blue', + title: 'Partner 1', + values: graph_values, + key: 'A key', + area: true, + }]); + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="graph_type"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="graph_data" t-att-graph_type="record.graph_type.raw_value" widget="dashboard_graph"/>' + + '</div>' + + '</t>' + + '</templates></kanban>', + domain: [['id', 'in', [1, 2]]], + }); + + // nvd3 seems to do a setTimeout(0) each time the addGraph function is + // called, which is done twice in this case as there are 2 records. + // for that reason, we need to do two setTimeout(0) as well here to ensure + // that both graphs are rendered before starting to check if the rendering + // is correct. + var done = assert.async(); + return concurrency.delay(0).then(function () { + return concurrency.delay(0); + }).then(function () { + assert.ok(kanban.$('.o_kanban_record:first() .o_graph_barchart').length, + "graph of first record should be a barchart"); + assert.ok(kanban.$('.o_kanban_record:nth(1) .o_graph_linechart').length, + "graph of second record should be a linechart"); + kanban.destroy(); + done(); + }); + + }); + + QUnit.module('AceEditor'); + + QUnit.test('ace widget on text fields works', function (assert) { + assert.expect(3); + var done = assert.async(); + + assert.notOk('ace' in window, "the ace library should not be loaded"); + this.data.partner.fields.foo.type = 'text'; + testUtils.createAsyncView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo" widget="ace"/>' + + '</form>', + res_id: 1, + }).then(function (form) { + assert.ok('ace' in window, "the ace library should now be loaded"); + assert.ok(form.$('div.ace_content').length, "should have rendered something with ace editor"); + delete window.require; + delete window.ace; + form.destroy(); + done(); + }); + }); + + QUnit.module('HandleWidget'); + + QUnit.test('handle widget', function (assert) { + assert.expect(7); + + this.data.partner.records[0].p = [2, 4]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="sequence" widget="handle"/>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('td span.o_row_handle').text(), "", + "handle should not have any content"); + + assert.notOk(form.$('td span.o_row_handle').is(':visible'), + "handle should be invisible in readonly mode"); + + assert.strictEqual(form.$('span.o_row_handle').length, 2, "should have 2 handles"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(form.$('td:first').hasClass('o_readonly'), + "column should be displayed as readonly"); + + assert.ok(form.$('td:first').hasClass('o_handle_cell'), + "column widget should be displayed in css class"); + + assert.ok(form.$('td span.o_row_handle').is(':visible'), + "handle should be visible in readonly mode"); + + form.$('td').eq(1).click(); + assert.strictEqual(form.$('td:first span.o_row_handle').length, 1, + "content of the cell should have been replaced"); + form.destroy(); + }); + + + QUnit.module('FieldDate'); + + QUnit.test('date field is empty if no date is set', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="date"/></form>', + res_id: 4, + }); + var $span = form.$('span.o_form_field'); + assert.strictEqual($span.length, 1, "should have one span in the form view"); + assert.strictEqual($span.text(), "", "and it should be empty"); + form.destroy(); + }); + + QUnit.test('date field in form view', function (assert) { + assert.expect(7); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="date"/></form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/write') { + assert.strictEqual(args.args[1].date, '2017-02-22', 'the correct value should be saved'); + } + return this._super.apply(this, arguments); + }, + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + } + }); + + assert.strictEqual(form.$('.o_form_field_date').text(), '02/03/2017', + 'the date should be correctly displayed in readonly'); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_datepicker_input').val(), '02/03/2017', + 'the date should be correct in edit mode'); + + // click on the input and select another value + form.$('.o_datepicker_input').click(); + assert.ok(form.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); + form.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection + form.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection + form.$('.bootstrap-datetimepicker-widget .year:contains(2017)').click(); + form.$('.bootstrap-datetimepicker-widget .month').eq(1).click(); // February + form.$('.day:contains(22)').click(); // select the 22 February + assert.ok(!form.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be closed'); + assert.strictEqual(form.$('.o_datepicker_input').val(), '02/22/2017', + 'the selected date should be displayed in the input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field_date').text(), '02/22/2017', + 'the selected date should be displayed after saving'); + form.destroy(); + }); + + QUnit.test('date field in editable list view', function (assert) { + assert.expect(8); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="date"/>' + + '</tree>', + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + } + }); + + var $cell = list.$('tr.o_data_row td:not(.o_list_record_selector)').first(); + assert.strictEqual($cell.text(), '02/03/2017', + 'the date should be displayed correctly in readonly'); + $cell.click(); + + assert.strictEqual(list.$('input.o_datepicker_input').length, 1, + "the view should have a date input for editable mode"); + + assert.strictEqual(list.$('input.o_datepicker_input').get(0), document.activeElement, + "date input should have the focus"); + + assert.strictEqual(list.$('input.o_datepicker_input').val(), '02/03/2017', + 'the date should be correct in edit mode'); + + // click on the input and select another value + list.$('input.o_datepicker_input').click(); + assert.ok(list.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); + list.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection + list.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection + list.$('.bootstrap-datetimepicker-widget .year:contains(2017)').click(); + list.$('.bootstrap-datetimepicker-widget .month').eq(1).click(); // February + list.$('.day:contains(22)').click(); // select the 22 February + assert.ok(!list.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be closed'); + assert.strictEqual(list.$('.o_datepicker_input').val(), '02/22/2017', + 'the selected date should be displayed in the input'); + + // save + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('tr.o_data_row td:not(.o_list_record_selector)').text(), '02/22/2017', + 'the selected date should be displayed after saving'); + + list.destroy(); + }); + + + QUnit.module('FieldDatetime'); + + QUnit.test('datetime field in form view', function(assert) { + assert.expect(6); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="datetime"/></form>', + res_id: 1, + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + } + }); + + var expectedDateString = "02/08/2017 10:00:00"; + assert.strictEqual(form.$('.o_form_field_date').text(), expectedDateString, + 'the datetime should be correctly displayed in readonly'); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_datepicker_input').val(), expectedDateString, + 'the datetime should be correct in edit mode'); + // click on the input and select 22 February at 8:23:33 + form.$('.o_datepicker_input').click(); + assert.ok(form.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); + form.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection + form.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection + form.$('.bootstrap-datetimepicker-widget .year:contains(2017)').click(); + form.$('.bootstrap-datetimepicker-widget .month').eq(3).click(); // April + form.$('.bootstrap-datetimepicker-widget .day:contains(22)').click(); + form.$('.bootstrap-datetimepicker-widget .fa-clock-o').click(); + form.$('.bootstrap-datetimepicker-widget .timepicker-hour').click(); + form.$('.bootstrap-datetimepicker-widget .hour:contains(08)').click(); + form.$('.bootstrap-datetimepicker-widget .timepicker-minute').click(); + form.$('.bootstrap-datetimepicker-widget .minute:contains(25)').click(); + form.$('.bootstrap-datetimepicker-widget .timepicker-second').click(); + form.$('.bootstrap-datetimepicker-widget .second:contains(35)').click(); + form.$('.bootstrap-datetimepicker-widget .fa-times').click(); // close + assert.ok(!form.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be closed'); + + var newExpectedDateString = "04/22/2017 08:25:35"; + assert.strictEqual(form.$('.o_datepicker_input').val(), newExpectedDateString, + 'the selected date should be displayed in the input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field_date').text(), newExpectedDateString, + 'the selected date should be displayed after saving'); + + form.destroy(); + }); + + QUnit.test('datetime field in editable list view', function (assert) { + assert.expect(8); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="datetime"/>' + + '</tree>', + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + } + }); + + var expectedDateString = "02/08/2017 10:00:00"; + var $cell = list.$('tr.o_data_row td:not(.o_list_record_selector)').first(); + assert.strictEqual($cell.text(), expectedDateString, + 'the datetime should be correctly displayed in readonly'); + + // switch to edit mode + $cell.click(); + assert.strictEqual(list.$('input.o_datepicker_input').length, 1, + "the view should have a date input for editable mode"); + + assert.strictEqual(list.$('input.o_datepicker_input').get(0), document.activeElement, + "date input should have the focus"); + + assert.strictEqual(list.$('input.o_datepicker_input').val(), expectedDateString, + 'the date should be correct in edit mode'); + + // click on the input and select 22 February at 8:23:33 + list.$('input.o_datepicker_input').click(); + assert.ok(list.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); + list.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection + list.$('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection + list.$('.bootstrap-datetimepicker-widget .year:contains(2017)').click(); + list.$('.bootstrap-datetimepicker-widget .month').eq(3).click(); // April + list.$('.bootstrap-datetimepicker-widget .day:contains(22)').click(); + list.$('.bootstrap-datetimepicker-widget .fa-clock-o').click(); + list.$('.bootstrap-datetimepicker-widget .timepicker-hour').click(); + list.$('.bootstrap-datetimepicker-widget .hour:contains(08)').click(); + list.$('.bootstrap-datetimepicker-widget .timepicker-minute').click(); + list.$('.bootstrap-datetimepicker-widget .minute:contains(25)').click(); + list.$('.bootstrap-datetimepicker-widget .timepicker-second').click(); + list.$('.bootstrap-datetimepicker-widget .second:contains(35)').click(); + list.$('.bootstrap-datetimepicker-widget .fa-times').click(); // close + assert.ok(!list.$('.bootstrap-datetimepicker-widget').length, 'datepicker should be closed'); + + var newExpectedDateString = "04/22/2017 08:25:35"; + assert.strictEqual(list.$('.o_datepicker_input').val(), newExpectedDateString, + 'the selected datetime should be displayed in the input'); + + // save + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('tr.o_data_row td:not(.o_list_record_selector)').text(), newExpectedDateString, + 'the selected datetime should be displayed after saving'); + + list.destroy(); + }); + + + QUnit.module('FieldMonetary'); + + QUnit.test('monetary field in form view', function(assert) { + assert.expect(5); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="qux" widget="monetary"/>' + + '<field name="currency_id" invisible="1"/>' + + '</sheet>' + + '</form>', + res_id: 5, + session: { + currencies: _.indexBy(this.data.currency.records, 'id'), + }, + }); + + // Non-breaking space between the currency and the amount + assert.strictEqual(form.$('.o_form_field').first().text(), '$\u00a09.10', + 'The value should be displayed properly.'); + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input.o_form_input').val(), '9.10', + 'The input should be rendered without the currency symbol.'); + assert.strictEqual(form.$('input.o_form_input').parent().children().first().text(), '$', + 'The input should be preceded by a span containing the currency symbol.'); + + form.$('input.o_form_input').val('108.2458938598598').trigger('input'); + assert.strictEqual(form.$('input.o_form_input').val(), '108.2458938598598', + 'The value should not be formated yet.'); + + form.$buttons.find('.o_form_button_save').click(); + // Non-breaking space between the currency and the amount + assert.strictEqual(form.$('.o_form_field').first().text(), '$\u00a0108.25', + 'The new value should be rounded properly.'); + + form.destroy(); + }); + + QUnit.test('monetary field with currency symbol after', function(assert) { + assert.expect(5); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="qux" widget="monetary"/>' + + '<field name="currency_id" invisible="1"/>' + + '</sheet>' + + '</form>', + res_id: 2, + session: { + currencies: _.indexBy(this.data.currency.records, 'id'), + }, + }); + + // Non-breaking space between the currency and the amount + assert.strictEqual(form.$('.o_form_field').first().text(), '0.00\u00a0€', + 'The value should be displayed properly.'); + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input.o_form_input').val(), '0.00', + 'The input should be rendered without the currency symbol.'); + assert.strictEqual(form.$('input.o_form_input').parent().children().eq(1).text(), '€', + 'The input should be followed by a span containing the currency symbol.'); + + form.$('input.o_form_input').val('108.2458938598598').trigger('input'); + assert.strictEqual(form.$('input.o_form_input').val(), '108.2458938598598', + 'The value should not be formated yet.'); + + form.$buttons.find('.o_form_button_save').click(); + // Non-breaking space between the currency and the amount + assert.strictEqual(form.$('.o_form_field').first().text(), '108.25\u00a0€', + 'The new value should be rounded properly.'); + + form.destroy(); + }); + + QUnit.test('monetary field in editable list view', function (assert) { + assert.expect(9); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="qux" widget="monetary"/>' + + '<field name="currency_id" invisible="1"/>' + + '</tree>', + session: { + currencies: _.indexBy(this.data.currency.records, 'id'), + }, + }); + + var dollarValues = list.$('td').filter(function() {return _.str.include($(this).text(), '$')}); + assert.strictEqual(dollarValues.length, 1, + 'Only one line has dollar as a currency.'); + + var euroValues = list.$('td').filter(function() {return _.str.include($(this).text(), '€')}); + assert.strictEqual(euroValues.length, 1, + 'One one line has euro as a currency.'); + + var zeroValues = list.$('td').filter(function() {return _.str.include($(this).text(), '0.00')}); + assert.strictEqual(zeroValues.length, 2, + 'Unset float values should be rendered as zeros.'); + + // switch to edit mode + var $cell = list.$('tr.o_data_row td:not(.o_list_record_selector):contains($)'); + $cell.click(); + + assert.strictEqual($cell.children().length, 1, + 'The cell td should only contain the special div of monetary widget.'); + assert.strictEqual(list.$('input.o_form_input').length, 1, + 'The view should have 1 input for editable monetary float.'); + assert.strictEqual(list.$('input.o_form_input').val(), '9.10', + 'The input should be rendered without the currency symbol.'); + assert.strictEqual(list.$('input.o_form_input').parent().children().first().text(), '$', + 'The input should be preceded by a span containing the currency symbol.'); + + list.$('input.o_form_input').val('108.2458938598598').trigger('input'); + assert.strictEqual(list.$('input.o_form_input').val(), '108.2458938598598', + 'The typed value should be correctly displayed.'); + + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('tr.o_data_row td:not(.o_list_record_selector):contains($)').text(), '$\u00a0108.25', + 'The new value should be rounded properly.'); + + list.destroy(); + }); + + QUnit.test('monetary field with real monetary field in model', function (assert) { + assert.expect(4); + + this.data.partner.fields.qux.type = "monetary"; + + this.data.partner.onchanges = { + bar: function (obj) { + obj.qux = obj.bar ? 100 : obj.qux; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="qux"/>' + + '<field name="currency_id"/>' + + '<field name="bar"/>' + + '</sheet>' + + '</form>', + res_id: 5, + session: { + currencies: _.indexBy(this.data.currency.records, 'id'), + }, + }); + + assert.strictEqual(form.$('.o_form_field_monetary').html(), "$ 9.10", + "readonly value should contain the currency"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_form_field_monetary > input').val(), "9.10", + "input value in edition should only contain the value, without the currency"); + + form.$('input[type="checkbox"]').click(); // Change the field on which the monetary depends + assert.strictEqual(form.$('.o_form_field_monetary > input').length, 1, + "After the onchange, the monetary <input/> should not have been duplicated"); + + var $dropdown = form.$('.o_form_field_many2one input').autocomplete('widget'); + form.$('.o_form_field_many2one input').click(); + $dropdown.find('li:not(.o_m2o_dropdown_option):last').mouseenter().click(); + assert.strictEqual(form.$('.o_form_field_monetary > span').html(), "€", + "After currency change, the monetary field currency should have been updated"); + + form.destroy(); + }); + + QUnit.module('FieldInteger'); + + QUnit.test('integer field when unset', function(assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="int_field"/></form>', + res_id: 4, + }); + + assert.ok(form.$('.o_form_field').hasClass('o_form_field_empty'), + 'Non-set integer field should be recognized as unset.'); + + form.destroy(); + }); + + QUnit.test('integer field in form view', function(assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="int_field"/></form>', + res_id: 2, + }); + + assert.ok(!form.$('.o_form_field').hasClass('o_form_field_empty'), + 'Integer field should be considered set for value 0.'); + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input.o_form_input').val(), '0', + 'The value should be rendered correctly in edit mode.') + + form.$('input.o_form_input').val('-18').trigger('input'); + assert.strictEqual(form.$('input.o_form_input').val(), '-18', + 'The value should be correctly displayed in the input.') + + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field').text(), '-18', + 'The new value should be saved and displayed properly.') + + form.destroy(); + }); + + QUnit.test('integer field in editable list view', function (assert) { + assert.expect(4); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="int_field"/>' + + '</tree>', + }); + + var zeroValues = list.$('td').filter(function() {return $(this).text() === '0'}); + assert.strictEqual(zeroValues.length, 1, + 'Unset integer values should not be rendered as zeros.'); + + // switch to edit mode + var $cell = list.$('tr.o_data_row td:not(.o_list_record_selector)').first(); + $cell.click(); + + assert.strictEqual(list.$('input.o_form_input').length, 1, + 'The view should have 1 input for editable integer.') + + list.$('input.o_form_input').val('-28').trigger('input'); + assert.strictEqual(list.$('input.o_form_input').val(), '-28', + 'The value should be displayed properly in the input.') + + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('td:not(.o_list_record_selector)').first().text(), '-28', + 'The new value should be saved and displayed properly.') + + list.destroy(); + }); + + + QUnit.module('FieldFloatTime'); + + QUnit.test('float_time field in form view', function(assert) { + assert.expect(5); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="qux" widget="float_time"/>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/write') { + // 48 / 60 = 0.8 + assert.strictEqual(args.args[1].qux, -11.8, 'the correct float value should be saved'); + } + return this._super.apply(this, arguments); + }, + res_id: 5, + }); + + // 9 + 0.1 * 60 = 9.06 + assert.strictEqual(form.$('.o_form_field').first().text(), '09:06', + 'The formatted time value should be displayed properly.') + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input.o_form_input').val(), '09:06', + 'The value should be rendered correctly in the input.') + + form.$('input.o_form_input').val('-11:48').trigger('input'); + assert.strictEqual(form.$('input.o_form_input').val(), '-11:48', + 'The new value should be displayed properly in the input.') + + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field').first().text(), '-11:48', + 'The new value should be saved and displayed properly.') + + form.destroy(); + }); + + + QUnit.module('PhoneWidget'); + + QUnit.test('phone field in form view on extra small screens', function (assert) { + assert.expect(7); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" widget="phone"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + config: { + device: { + size_class: 0, // Screen XS + SIZES: { XS: 0, SM: 1, MD: 2, LG: 3 }, + } + }, + }); + + var $phoneLink = form.$('a.o_form_uri.o_form_field.o_text_overflow'); + assert.strictEqual($phoneLink.length, 1, + "should have a anchor with correct classes"); + assert.strictEqual($phoneLink.text(), 'y\u00ADop', + "value should be displayed properly as text with the skype obfuscation"); + assert.strictEqual($phoneLink.attr('href'), 'tel:yop', + "should have proper tel prefix"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').length, 1, + "should have an input for the phone field"); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').val(), 'yop', + "input should contain field value in edit mode"); + + // change value in edit mode + form.$('input[type="text"].o_form_input.o_form_field').val('new').trigger('input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + $phoneLink = form.$('a.o_form_uri.o_form_field.o_text_overflow'); + assert.strictEqual($phoneLink.text(), 'n\u00ADew', + "new value should be displayed properly as text with the skype obfuscation"); + assert.strictEqual($phoneLink.attr('href'), 'tel:new', + "should still have proper tel prefix"); + + form.destroy(); + }); + + QUnit.test('phone field in editable list view on extra small screens', function(assert) { + assert.expect(10); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>', + config: { + device: { + size_class: 0, // Screen XS + SIZES: { XS: 0, SM: 1, MD: 2, LG: 3 }, + } + }, + }); + + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').length, 5, + "should have 5 cells"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'y\u00ADop', + "value should be displayed properly as text with the skype obfuscation"); + + var $phoneLink = list.$('a.o_form_uri.o_field_widget.o_text_overflow'); + assert.strictEqual($phoneLink.length, 5, + "should have anchors with correct classes"); + assert.strictEqual($phoneLink.first().attr('href'), 'tel:yop', + "should have proper tel prefix"); + + // Edit a line and check the result + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + $cell.click(); + assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode'); + assert.strictEqual($cell.find('input').val(), 'yop', + 'should have the corect value in internal input'); + $cell.find('input').val('new').trigger('input');; + + // save + list.$buttons.find('.o_list_button_save').click(); + $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore'); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'n\u00ADew', + "value should be properly updated"); + $phoneLink = list.$('a.o_form_uri.o_field_widget.o_text_overflow'); + assert.strictEqual($phoneLink.length, 5, + "should still have anchors with correct classes"); + assert.strictEqual($phoneLink.first().attr('href'), 'tel:new', + "should still have proper tel prefix"); + + list.destroy(); + }); + + QUnit.test('phone field in form view on normal screens', function (assert) { + // The behavior of this widget is completely altered by crm_voip so this + // test is irrelevant and fails if crm_voip is installed. The enterprise + // module is responsible for testing its own behavior in its own tests. + if ('crm.voip' in odoo.__DEBUG__.services) { + assert.expect(0); + return; + } + + assert.expect(5); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" widget="phone"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + config: { + device: { + size_class: 1, // Screen SM + SIZES: { XS: 0, SM: 1, MD: 2, LG: 3 }, + } + }, + }); + + var $phone = form.$('span.o_form_field.o_text_overflow:not(.o_form_uri)'); + assert.strictEqual($phone.length, 1, + "should have a simple span rather than a link"); + assert.strictEqual($phone.text(), 'yop', + "value should be displayed properly as text without skype obfuscation"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').length, 1, + "should have an input for the phone field"); + assert.strictEqual(form.$('input[type="text"].o_form_input.o_form_field').val(), 'yop', + "input should contain field value in edit mode"); + + // change value in edit mode + form.$('input[type="text"].o_form_input.o_form_field').val('new').trigger('input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('span.o_form_field.o_text_overflow:not(.o_form_uri)').text(), 'new', + "new value should be displayed properly as text without skype obfuscation"); + + form.destroy(); + }); + + QUnit.test('phone field in editable list view on normal screens', function(assert) { + // The behavior of this widget is completely altered by crm_voip so this + // test is irrelevant and fails if crm_voip is installed. The enterprise + // module is responsible for testing its own behavior in its own tests. + if ('crm.voip' in odoo.__DEBUG__.services) { + assert.expect(0); + return; + } + + assert.expect(8); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>', + config: { + device: { + size_class: 1, // Screen SM + SIZES: { XS: 0, SM: 1, MD: 2, LG: 3 }, + } + }, + }); + + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').length, 5, + "should have 5 cells"); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'yop', + "value should be displayed properly as text without skype obfuscation"); + + assert.strictEqual(list.$('span.o_field_widget.o_text_overflow:not(.o_form_uri)').length, 5, + "should have spans with correct classes"); + + // Edit a line and check the result + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + $cell.click(); + assert.ok($cell.hasClass('o_edit_mode'), 'should be set as edit mode'); + assert.strictEqual($cell.find('input').val(), 'yop', + 'should have the corect value in internal input'); + $cell.find('input').val('new').trigger('input');; + + // save + list.$buttons.find('.o_list_button_save').click(); + $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + assert.ok(!$cell.hasClass('o_edit_mode'), 'should not be in edit mode anymore'); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'new', + "value should be properly updated"); + assert.strictEqual(list.$('span.o_field_widget.o_text_overflow:not(.o_form_uri)').length, 5, + "should still have spans with correct classes"); + + list.destroy(); + }); + + + QUnit.module('PriorityWidget'); + + QUnit.test('priority widget when not set', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="selection" widget="priority"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_form_field.o_priority:not(.o_form_field_empty)').length, 1, + "widget should be considered set, even though there is no value for this field"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should have two stars for representing each possible value: no star, one star and two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 0, + "should have no full star since there is no value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 2, + "should have two empty stars since there is no value"); + + form.destroy(); + }); + + QUnit.test('priority widget in form view', function (assert) { + assert.expect(22); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="selection" widget="priority"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_form_field.o_priority:not(.o_form_field_empty)').length, 1, + "widget should be considered set"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should have two stars for representing each possible value: no star, one star and two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 1, + "should have one full star since the value is the second value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 1, + "should have one empty star since the value is the second value"); + + // hover last star + form.$('.o_form_field.o_priority a.o_priority_star.fa-star-o').last().trigger('mouseover'); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should have two stars for representing each possible value: no star, one star and two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 2, + "should temporary have two full stars since we are hovering the third value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 0, + "should temporary have no empty star since we are hovering the third value"); + + // Here we should test with mouseout, but currently the effect associated with it + // occurs in a setTimeout after 200ms so it's not trivial to test it here. + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 1, + "should still have one full star since the value is the second value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 1, + "should still have one empty star since the value is the second value"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 1, + "should still have one full star since the value is the second value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 1, + "should still have one empty star since the value is the second value"); + + // switch to edit mode to check that the new value was properly written + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 1, + "should still have one full star since the value is the second value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 1, + "should still have one empty star since the value is the second value"); + + // click on the second star in edit mode + form.$('.o_form_field.o_priority a.o_priority_star.fa-star-o').last().click(); + + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 2, + "should now have two full stars since the value is the third value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 0, + "should now have no empty star since the value is the third value"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star').length, 2, + "should now have two full stars since the value is the third value"); + assert.strictEqual(form.$('.o_form_field.o_priority').find('a.o_priority_star.fa-star-o').length, 0, + "should now have no empty star since the value is the third value"); + + form.destroy(); + }); + + QUnit.test('priority widget in editable list view', function(assert) { + assert.expect(25); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="selection" widget="priority"/></tree>', + }); + + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority:not(.o_form_field_empty)').length, 1, + "widget should be considered set"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star').length, 2, + "should have two stars for representing each possible value: no star, one star and two stars"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star').length, 1, + "should have one full star since the value is the second value"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star-o').length, 1, + "should have one empty star since the value is the second value"); + + // Here we should test with mouseout, but currently the effect associated with it + // occurs in a setTimeout after 200ms so it's not trivial to test it here. + + // switch to edit mode and check the result + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + $cell.click(); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star').length, 2, + "should have two stars for representing each possible value: no star, one star and two stars"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star').length, 1, + "should have one full star since the value is the second value"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star-o').length, 1, + "should have one empty star since the value is the second value"); + + // save + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star').length, 2, + "should have two stars for representing each possible value: no star, one star and two stars"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star').length, 1, + "should have one full star since the value is the second value"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star-o').length, 1, + "should have one empty star since the value is the second value"); + + // hover last star + list.$('.o_data_row .o_priority a.o_priority_star.fa-star-o').first().trigger('mouseenter'); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star').length, 2, + "should have two stars for representing each possible value: no star, one star and two stars"); + assert.strictEqual(list.$('.o_data_row').first().find('a.o_priority_star.fa-star').length, 2, + "should temporary have two full stars since we are hovering the third value"); + assert.strictEqual(list.$('.o_data_row').first().find('a.o_priority_star.fa-star-o').length, 0, + "should temporary have no empty star since we are hovering the third value"); + + // click on the first star in readonly mode + list.$('.o_priority a.o_priority_star.fa-star').first().click(); + + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star').length, 0, + "should now have no full star since the value is the first value"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star-o').length, 2, + "should now have two empty stars since the value is the first value"); + + // re-enter edit mode to force re-rendering the widget to check if the value was correctly saved + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + $cell.click(); + + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star').length, 0, + "should now only have no full star since the value is the first value"); + assert.strictEqual(list.$('.o_data_row').first().find('.o_priority a.o_priority_star.fa-star-o').length, 2, + "should now have two empty stars since the value is the first value"); + + // Click on second star in edit mode + list.$('.o_priority a.o_priority_star.fa-star-o').last().click(); + + assert.strictEqual(list.$('.o_data_row').last().find('.o_priority a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(list.$('.o_data_row').last().find('.o_priority a.o_priority_star.fa-star').length, 2, + "should now have two full stars since the value is the third value"); + assert.strictEqual(list.$('.o_data_row').last().find('.o_priority a.o_priority_star.fa-star-o').length, 0, + "should now have no empty star since the value is the third value"); + + // save + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('.o_data_row').last().find('.o_priority a.o_priority_star').length, 2, + "should still have two stars"); + assert.strictEqual(list.$('.o_data_row').last().find('.o_priority a.o_priority_star.fa-star').length, 2, + "should now have two full stars since the value is the third value"); + assert.strictEqual(list.$('.o_data_row').last().find('.o_priority a.o_priority_star.fa-star-o').length, 0, + "should now have no empty star since the value is the third value"); + + list.destroy(); + }); + + + QUnit.module('StateSelection Widget'); + + QUnit.test('state_selection widget in form view', function (assert) { + assert.expect(21); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="selection" widget="state_selection"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_red').length, 1, + "should have one red status since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_green').length, 0, + "should not have one green status since selection is the second, blocked state"); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown"); + + // Click on the status button to make the dropdown appear + form.$('.o_form_field.o_selection .o_status').first().click(); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible').length, 1, + "there should be a dropdown"); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible li').length, 2, + "there should be two options in the dropdown"); + + // Click on the first option, "Normal" + form.$('ul.dropdown-menu.state:visible li').first().click(); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown anymore"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_red').length, 0, + "should not have one red status since selection is the first, normal state"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_green').length, 0, + "should not have one green status since selection is the first, normal state"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status').length, 1, + "should have one grey status since selection is the first, normal state"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible').length, 0, + "there should still not be a dropdown"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_red').length, 0, + "should still not have one red status since selection is the first, normal state"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_green').length, 0, + "should still not have one green status since selection is the first, normal state"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status').length, 1, + "should still have one grey status since selection is the first, normal state"); + + // Click on the status button to make the dropdown appear + form.$('.o_form_field.o_selection .o_status').first().click(); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible').length, 1, + "there should be a dropdown"); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible li').length, 2, + "there should be two options in the dropdown"); + + // Click on the last option, "Done" + form.$('ul.dropdown-menu.state:visible li').last().click(); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown anymore"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_red').length, 0, + "should not have one red status since selection is the third, done state"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_green').length, 1, + "should have one green status since selection is the third, done state"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('ul.dropdown-menu.state:visible').length, 0, + "there should still not be a dropdown anymore"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_red').length, 0, + "should still not have one red status since selection is the third, done state"); + assert.strictEqual(form.$('.o_form_field.o_selection > a span.o_status.o_status_green').length, 1, + "should still have one green status since selection is the third, done state"); + + form.destroy(); + }); + + QUnit.test('state_selection widget in editable list view', function(assert) { + assert.expect(34); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="selection" widget="state_selection"/>' + + '</tree>', + }); + + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status').length, 5, + "should have five status selection widgets"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_red').length, 1, + "should have one red status"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_green').length, 1, + "should have one green status"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown"); + + // Click on the status button to make the dropdown appear + var $cell = list.$('tbody td.o_state_selection_cell').first(); + list.$('.o_state_selection_cell .o_selection > a span.o_status').first().click(); + assert.ok(!$cell.hasClass('o_edit_mode'), + 'should not be in edit mode since we clicked on the widget and not the cell itself'); + assert.ok(!$cell.siblings().hasClass('o_edit_mode'), + 'siblings should not be in edit mode since we clicked on the widget and not the cell itself'); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 1, + "there should be a dropdown"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible li').length, 2, + "there should be two options in the dropdown"); + + // Click on the first option, "Normal" + list.$('ul.dropdown-menu.state:visible li').first().click(); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status').length, 5, + "should still have five status selection widgets"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_red').length, 0, + "should now have no red status"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_green').length, 1, + "should still have one green status"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown"); + + // switch to edit mode and check the result + $cell = list.$('tbody td.o_state_selection_cell').first(); + $cell.click(); + assert.ok(!$cell.hasClass('o_edit_mode'), + 'should still not be in edit mode since widget is readonly'); + assert.ok($cell.siblings().hasClass('o_edit_mode'), + 'siblings should be in edit mode since we clicked on the cell and not the widget itself'); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status').length, 5, + "should still have five status selection widgets"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_red').length, 0, + "should now have no red status"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_green').length, 1, + "should still have one green status"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown"); + + // Click on the status button to make the dropdown appear + list.$('.o_state_selection_cell .o_selection > a span.o_status').first().click(); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 1, + "there should be a dropdown"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible li').length, 2, + "there should be two options in the dropdown"); + + // Click on another row + var $firstCell = list.$('tbody td.o_state_selection_cell').first(); + var $lastCell = list.$('tbody td.o_state_selection_cell').last(); + $lastCell.click(); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown anymore"); + assert.ok(!$firstCell.siblings().hasClass('o_edit_mode'), + 'first row should not be in edit mode anymore'); + assert.ok($lastCell.siblings().hasClass('o_edit_mode'), + 'last row should be in edit mode'); + + // Click on the last status button to make the dropdown appear + list.$('.o_state_selection_cell .o_selection > a span.o_status').last().click(); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 1, + "there should be a dropdown"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible li').length, 2, + "there should be two options in the dropdown"); + + // Click on the last option, "Done" + list.$('ul.dropdown-menu.state:visible li').last().click(); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown anymore"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status').length, 5, + "should still have five status selection widgets"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_red').length, 0, + "should still have no red status"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_green').length, 2, + "should now have two green status"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown"); + + // save + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status').length, 5, + "should have five status selection widgets"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_red').length, 0, + "should have no red status"); + assert.strictEqual(list.$('.o_state_selection_cell .o_selection > a span.o_status.o_status_green').length, 2, + "should have two green status"); + assert.strictEqual(list.$('ul.dropdown-menu.state:visible').length, 0, + "there should not be a dropdown"); + + list.destroy(); + }); + + + QUnit.module('LabelSelectionWidget'); + + QUnit.test('label_selection widget in form view', function (assert) { + assert.expect(12); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="selection" widget="label_selection" ' + + ' options="{\'classes\': {\'normal\': \'default\', \'blocked\': \'warning\',\'done\': \'success\'}}"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_form_field.label.label-warning').length, 1, + "should have a warning status label since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-default').length, 0, + "should not have a default status since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-success').length, 0, + "should not have a success status since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-warning').text(), 'Blocked', + "the label should say 'Blocked' since this is the label value for that state"); + + // // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_field.label.label-warning').length, 1, + "should have a warning status label since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-default').length, 0, + "should not have a default status since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-success').length, 0, + "should not have a success status since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-warning').text(), 'Blocked', + "the label should say 'Blocked' since this is the label value for that state"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field.label.label-warning').length, 1, + "should have a warning status label since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-default').length, 0, + "should not have a default status since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-success').length, 0, + "should not have a success status since selection is the second, blocked state"); + assert.strictEqual(form.$('.o_form_field.label.label-warning').text(), 'Blocked', + "the label should say 'Blocked' since this is the label value for that state"); + + form.destroy(); + }); + + QUnit.test('label_selection widget in editable list view', function(assert) { + assert.expect(21); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="selection" widget="label_selection"' + + ' options="{\'classes\': {\'normal\': \'default\', \'blocked\': \'warning\',\'done\': \'success\'}}"/>' + + '</tree>', + }); + + assert.strictEqual(list.$('.o_field_widget.label:not(:empty)').length, 3, + "should have three visible status labels"); + assert.strictEqual(list.$('.o_field_widget.label.label-warning').length, 1, + "should have one warning status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-warning').text(), 'Blocked', + "the warning label should read 'Blocked'"); + assert.strictEqual(list.$('.o_field_widget.label.label-default').length, 1, + "should have one default status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-default').text(), 'Normal', + "the default label should read 'Normal'"); + assert.strictEqual(list.$('.o_field_widget.label.label-success').length, 1, + "should have one success status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-success').text(), 'Done', + "the success label should read 'Done'"); + + // switch to edit mode and check the result + list.$('tbody td:not(.o_list_record_selector)').first().click(); + assert.strictEqual(list.$('.o_field_widget.label:not(:empty)').length, 3, + "should have three visible status labels"); + assert.strictEqual(list.$('.o_field_widget.label.label-warning').length, 1, + "should have one warning status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-warning').text(), 'Blocked', + "the warning label should read 'Blocked'"); + assert.strictEqual(list.$('.o_field_widget.label.label-default').length, 1, + "should have one default status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-default').text(), 'Normal', + "the default label should read 'Normal'"); + assert.strictEqual(list.$('.o_field_widget.label.label-success').length, 1, + "should have one success status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-success').text(), 'Done', + "the success label should read 'Done'"); + + // save and check the result + list.$buttons.find('.o_list_button_save').click(); + assert.strictEqual(list.$('.o_field_widget.label:not(:empty)').length, 3, + "should have three visible status labels"); + assert.strictEqual(list.$('.o_field_widget.label.label-warning').length, 1, + "should have one warning status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-warning').text(), 'Blocked', + "the warning label should read 'Blocked'"); + assert.strictEqual(list.$('.o_field_widget.label.label-default').length, 1, + "should have one default status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-default').text(), 'Normal', + "the default label should read 'Normal'"); + assert.strictEqual(list.$('.o_field_widget.label.label-success').length, 1, + "should have one success status label"); + assert.strictEqual(list.$('.o_field_widget.label.label-success').text(), 'Done', + "the success label should read 'Done'"); + + list.destroy(); + }); + + + QUnit.module('StatInfo'); + + QUnit.test('statinfo widget in form view', function (assert) { + assert.expect(9); + + var form = 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" name="items" type="object" icon="fa-gear">' + + '<field name="int_field" widget="statinfo"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + 'int_field', "should have 'int_field' as text"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should still have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should still have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + 'int_field', "should have 'int_field' as text"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + 'int_field', "should have 'int_field' as text"); + + form.destroy(); + }); + + QUnit.test('statinfo widget in form view with specific label_field', function (assert) { + assert.expect(9); + + var form = 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" name="items" type="object" icon="fa-gear">' + + '<field string="Useful stat button" name="int_field" widget="statinfo" ' + + 'options="{\'label_field\': \'foo\'}"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo" invisible="1"/>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + 'yop', "should have 'yop' as text, since it is the value of field foo"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should still have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should still have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + 'yop', "should have 'yop' as text, since it is the value of field foo"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + 'yop', "should have 'yop' as text, since it is the value of field foo"); + + form.destroy(); + }); + + QUnit.test('statinfo widget in form view with no label', function (assert) { + assert.expect(9); + + var form = 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" name="items" type="object" icon="fa-gear">' + + '<field string="Useful stat button" name="int_field" widget="statinfo" ' + + 'options="{\'nolabel\': \'true\'}"/>' + + '</button>' + + '</div>' + + '<group>' + + '<field name="foo" invisible="1"/>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + '', "should not have any label"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should still have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should still have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + '', "should not have any label"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info').length, 1, + "should have one stat button"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_value').text(), + '10', "should have 10 as value"); + assert.strictEqual(form.$('.oe_stat_button .o_form_field.o_stat_info .o_stat_text').text(), + '', "should not have any label"); + + form.destroy(); + }); + + + QUnit.module('PercentPie'); + + QUnit.test('percentpie widget in form view with value < 50%', function (assert) { + assert.expect(12); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="int_field" widget="percentpie"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie').length, 1, + "should have a pie chart"); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_pie_value').text(), + '10%', "should have 10% as pie value since int_field=10"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').first().attr('style'), + 'transform: rotate(180deg);'), "left mask should be covering the whole left side of the pie"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').last().attr('style'), + 'transform: rotate(36deg);'), "right mask should be rotated from 360*(10/100) = 36 degrees"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie').length, 1, + "should have a pie chart"); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_pie_value').text(), + '10%', "should have 10% as pie value since int_field=10"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').first().attr('style'), + 'transform: rotate(180deg);'), "left mask should be covering the whole left side of the pie"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').last().attr('style'), + 'transform: rotate(36deg);'), "right mask should be rotated from 360*(10/100) = 36 degrees"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie').length, 1, + "should have a pie chart"); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_pie_value').text(), + '10%', "should have 10% as pie value since int_field=10"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').first().attr('style'), + 'transform: rotate(180deg);'), "left mask should be covering the whole left side of the pie"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').last().attr('style'), + 'transform: rotate(36deg);'), "right mask should be rotated from 360*(10/100) = 36 degrees"); + + form.destroy(); + }); + + QUnit.test('percentpie widget in form view with value > 50%', function (assert) { + assert.expect(12); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="int_field" widget="percentpie"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 3, + }); + + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie').length, 1, + "should have a pie chart"); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_pie_value').text(), + '80%', "should have 80% as pie value since int_field=80"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').first().attr('style'), + 'transform: rotate(288deg);'), "left mask should be rotated from 360*(80/100) = 288 degrees"); + assert.ok(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').last().hasClass('o_full'), + "right mask should be hidden since the value > 50%"); + + // switch to edit mode and check the result + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie').length, 1, + "should have a pie chart"); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_pie_value').text(), + '80%', "should have 80% as pie value since int_field=80"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').first().attr('style'), + 'transform: rotate(288deg);'), "left mask should be rotated from 360*(80/100) = 288 degrees"); + assert.ok(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').last().hasClass('o_full'), + "right mask should be hidden since the value > 50%"); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie').length, 1, + "should have a pie chart"); + assert.strictEqual(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_pie_value').text(), + '80%', "should have 80% as pie value since int_field=80"); + assert.ok(_.str.include(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').first().attr('style'), + 'transform: rotate(288deg);'), "left mask should be rotated from 360*(80/100) = 288 degrees"); + assert.ok(form.$('.o_form_field_percent_pie.o_form_field .o_pie .o_mask').last().hasClass('o_full'), + "right mask should be hidden since the value > 50%"); + + form.destroy(); + }); + + // TODO: This test would pass without any issue since all the classes and + // custom style attributes are correctly set on the widget in list + // view, but since the less itself for this widget currently only + // applies inside the form view, the widget is unusable. This test can + // be uncommented when we refactor the less files so that this widget + // stylesheet applies in both form and list view. + // QUnit.test('percentpie widget in editable list view', function(assert) { + // assert.expect(10); + // + // var list = createView({ + // View: ListView, + // model: 'partner', + // data: this.data, + // arch: '<tree editable="bottom">' + + // '<field name="foo"/>' + + // '<field name="int_field" widget="percentpie"/>' + + // '</tree>', + // }); + // + // assert.strictEqual(list.$('.o_form_field_percent_pie .o_pie').length, 5, + // "should have five pie charts"); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_pie_value').first().text(), + // '10%', "should have 10% as pie value since int_field=10"); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_mask').first().attr('style'), + // 'transform: rotate(180deg);', "left mask should be covering the whole left side of the pie"); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_mask').last().attr('style'), + // 'transform: rotate(36deg);', "right mask should be rotated from 360*(10/100) = 36 degrees"); + // + // // switch to edit mode and check the result + // list.$('tbody td:not(.o_list_record_selector)').first().click(); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_pie_value').first().text(), + // '10%', "should have 10% as pie value since int_field=10"); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_mask').first().attr('style'), + // 'transform: rotate(180deg);', "left mask should be covering the whole right side of the pie"); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_mask').last().attr('style'), + // 'transform: rotate(36deg);', "right mask should be rotated from 360*(10/100) = 36 degrees"); + // + // // save + // list.$buttons.find('.o_list_button_save').click(); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_pie_value').first().text(), + // '10%', "should have 10% as pie value since int_field=10"); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_mask').first().attr('style'), + // 'transform: rotate(180deg);', "left mask should be covering the whole right side of the pie"); + // assert.strictEqual(list.$('.o_form_field_percent_pie:first .o_pie .o_mask').last().attr('style'), + // 'transform: rotate(36deg);', "right mask should be rotated from 360*(10/100) = 36 degrees"); + // + // list.destroy(); + // }); + + + QUnit.module('FieldDomain'); + + QUnit.test('basic domain field usage is ok', function (assert) { + assert.expect(6); + + this.data.partner.records[0].foo = "[]"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<sheet>' + + '<group>' + + '<field name="foo" widget="domain" options="{\'model\': \'partner_type\'}"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + form.$buttons.find('.o_form_button_edit').click(); + + // As the domain is empty, there should be a button to add the first + // domain part + var $domain = form.$(".o_form_field_domain"); + var $domainAddFirstNodeButton = $domain.find(".o_domain_add_first_node_button"); + assert.equal($domainAddFirstNodeButton.length, 1, + "there should be a button to create first domain element"); + + // Clicking on the button should add the [["id", "=", "1"]] domain, so + // there should be a field selector in the DOM + $domainAddFirstNodeButton.click(); + var $fieldSelector = $domain.find(".o_field_selector"); + assert.equal($fieldSelector.length, 1, + "there should be a field selector"); + + // Focusing the field selector input should open the field selector + // popover + $fieldSelector.trigger('focusin'); + var $fieldSelectorPopover = $fieldSelector.find(".o_field_selector_popover"); + assert.ok($fieldSelectorPopover.is(":visible"), + "field selector popover should be visible"); + + // The popover should contain the list of partner_type fields and so + // there should be the "Color index" field + var $lis = $fieldSelectorPopover.find("li"); + var $colorIndex = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Color index") >= 0) { + $colorIndex = $li; + } + }); + assert.equal($colorIndex.length, 1, + "field selector popover should contain 'Color index' field"); + + // Clicking on this field should close the popover, then changing the + // associated value should reveal one matched record + $colorIndex.click(); + $domain.find(".o_domain_leaf_value_input").val(2).change(); + assert.equal($domain.find(".o_domain_show_selection_button").text().trim().substr(0, 2), "1 ", + "changing color value to 2 should reveal only one record"); + + // Saving the form view should show a readonly domain containing the + // "color" field + form.$buttons.find('.o_form_button_save').click(); + $domain = form.$(".o_form_field_domain"); + assert.ok($domain.html().indexOf("Color index") >= 0, + "field selector readonly value should now contain 'Color index'"); + form.destroy(); + }); + + QUnit.test('domain field is correctly reset on every view change', function (assert) { + assert.expect(7); + + this.data.partner.records[0].foo = '[["id","=",1]]'; + this.data.partner.fields.bar.type = "char"; + this.data.partner.records[0].bar = "product"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<sheet>' + + '<group>' + + '<field name="bar"/>' + + '<field name="foo" widget="domain" options="{\'model\': \'bar\'}"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + form.$buttons.find('.o_form_button_edit').click(); + + // As the domain is equal to [["id", "=", 1]] there should be a field + // selector to change this + var $domain = form.$(".o_form_field_domain"); + var $fieldSelector = $domain.find(".o_field_selector"); + assert.equal($fieldSelector.length, 1, + "there should be a field selector"); + + // Focusing its input should open the field selector popover + $fieldSelector.trigger('focusin'); + var $fieldSelectorPopover = $fieldSelector.find(".o_field_selector_popover"); + assert.ok($fieldSelectorPopover.is(":visible"), + "field selector popover should be visible"); + + // As the value of the "bar" field is "product", the field selector + // popover should contain the list of "product" fields + var $lis = $fieldSelectorPopover.find("li"); + var $sampleLi = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Product Name") >= 0) { + $sampleLi = $li; + } + }); + assert.strictEqual($lis.length, 1, + "field selector popover should contain only one field"); + assert.strictEqual($sampleLi.length, 1, + "field selector popover should contain 'Product Name' field"); + + // Now change the value of the "bar" field to "partner_type" + form.$(".o_field_widget.o_form_input").click().val("partner_type").trigger("input"); + + // Refocusing the field selector input should open the popover again + $fieldSelector = form.$(".o_field_selector"); + $fieldSelector.trigger('focusin'); + $fieldSelectorPopover = $fieldSelector.find(".o_field_selector_popover"); + assert.ok($fieldSelectorPopover.is(":visible"), + "field selector popover should be visible"); + + // Now the list of fields should be the ones of the "partner_type" model + $lis = $fieldSelectorPopover.find("li"); + $sampleLi = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Color index") >= 0) { + $sampleLi = $li; + } + }); + assert.strictEqual($lis.length, 2, + "field selector popover should contain two fields"); + assert.strictEqual($sampleLi.length, 1, + "field selector popover should contain 'Color index' field"); + form.destroy(); + }); +}); +}); +}); diff --git a/addons/web/static/tests/fields/field_utils_tests.js b/addons/web/static/tests/fields/field_utils_tests.js new file mode 100644 index 00000000000..5b3eb267714 --- /dev/null +++ b/addons/web/static/tests/fields/field_utils_tests.js @@ -0,0 +1,81 @@ +odoo.define('web.field_utils_tests', function (require) { +"use strict"; + +var core = require('web.core'); +var fieldUtils = require('web.field_utils'); + +QUnit.module('fields', {}, function () { + +QUnit.module('field_utils'); + +QUnit.test('format integer', function(assert) { + assert.expect(5); + + var originalGrouping = core._t.database.parameters.grouping; + + core._t.database.parameters.grouping = [3, 3, 3, 3]; + assert.strictEqual(fieldUtils.format.integer(1000000), '1,000,000'); + + core._t.database.parameters.grouping = [3, 2, -1]; + assert.strictEqual(fieldUtils.format.integer(106500), '1,06,500'); + + core._t.database.parameters.grouping = [1, 2, -1]; + assert.strictEqual(fieldUtils.format.integer(106500), '106,50,0'); + + assert.strictEqual(fieldUtils.format.integer(0), "0"); + assert.strictEqual(fieldUtils.format.integer(undefined), ""); + + core._t.database.parameters.grouping = originalGrouping; +}); + +QUnit.test('format float', function(assert) { + assert.expect(4); + + var originalParameters = $.extend(true, {}, core._t.database.parameters); + + core._t.database.parameters.grouping = [3, 3, 3, 3]; + assert.strictEqual(fieldUtils.format.float(1000000), '1,000,000.00'); + + core._t.database.parameters.grouping = [3, 2, -1]; + assert.strictEqual(fieldUtils.format.float(106500), '1,06,500.00'); + + core._t.database.parameters.grouping = [1, 2, -1]; + assert.strictEqual(fieldUtils.format.float(106500), '106,50,0.00'); + + _.extend(core._t.database.parameters, { + grouping: [3, 0], + decimal_point: ',', + thousands_sep: '.' + }); + assert.strictEqual(fieldUtils.format.float(6000), '6.000,00'); + + core._t.database.parameters = originalParameters; +}); + +QUnit.test("format_datetime", function (assert) { + assert.expect(1); + + var date_string = "2009-05-04 12:34:23"; + var date = fieldUtils.parse.datetime(date_string); + var str = fieldUtils.format.datetime(date); + assert.strictEqual(str, moment(date).format("MM/DD/YYYY HH:mm:ss")); +}); + +QUnit.test("format_many2one", function (assert) { + assert.expect(2); + + assert.strictEqual('', fieldUtils.format.many2one(null)); + assert.strictEqual('A M2O value', fieldUtils.format.many2one({ + data: { display_name: 'A M2O value' }, + })); +}); + + +QUnit.test('formatting chars', function(assert) { + assert.expect(1); + + assert.strictEqual(fieldUtils.format.char(), '', + "undefined char should be formatted as an empty string"); +}); +}); +}); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js new file mode 100644 index 00000000000..86e569fdbb5 --- /dev/null +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -0,0 +1,3742 @@ +odoo.define('web.relational_fields_tests', function (require) { +"use strict"; + +var FormView = require('web.FormView'); +var ListView = require('web.ListView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +QUnit.module('fields', {}, function () { + +QUnit.module('relational_fields', { + 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", default: true}, + 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', relation_field: 'trululu'}, + turtles: {string: "one2many turtle field", type: "one2many", relation: 'turtle'}, + trululu: {string: "Trululu", type: "many2one", relation: 'partner'}, + timmy: { string: "pokemon", type: "many2many", relation: 'partner_type'}, + product_id: {string: "Product", type: "many2one", relation: 'product'}, + color: { + type: "selection", + selection: [['red', "Red"], ['black', "Black"]], + default: 'red', + }, + date: {string: "Some Date", type: "date"}, + datetime: {string: "Datetime Field", type: 'datetime'}, + user_id: {string: "User", type: 'many2one', relation: 'user'}, + }, + records: [{ + id: 1, + display_name: "first record", + bar: true, + foo: "yop", + int_field: 10, + qux: 0.44, + p: [], + turtles: [2], + timmy: [], + trululu: 4, + user_id: 17, + }, { + id: 2, + display_name: "second record", + bar: true, + foo: "blip", + int_field: 9, + qux: 13, + p: [], + timmy: [], + trululu: 1, + product_id: 37, + date: "2017-01-25", + datetime: "2016-12-12 10:55:05", + user_id: 17, + }, { + id: 4, + display_name: "aaa", + bar: false, + }], + onchanges: {}, + }, + product: { + fields: { + name: {string: "Product Name", type: "char"} + }, + 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: "int"}, + }, + records: [ + {id: 12, display_name: "gold", color: 2}, + {id: 14, display_name: "silver", color: 5}, + ] + }, + turtle: { + fields: { + display_name: { string: "Displayed name", type: "char" }, + turtle_foo: {string: "Foo", type: "char", default: "My little Foo Value"}, + turtle_bar: {string: "Bar", type: "boolean", default: true}, + turtle_int: {string: "int", type: "integer", sortable: true}, + turtle_qux: {string: "Qux", type: "float", digits: [16,1], required: true, default: 1.5}, + turtle_description: {string: "Description", type: "text"}, + turtle_trululu: {string: "Trululu", type: "many2one", relation: 'partner'}, + product_id: {string: "Product", type: "many2one", relation: 'product', required: true}, + partner_ids: {string: "Partner", type: "many2many", relation: 'partner'}, + }, + records: [{ + id: 1, + display_name: "leonardo", + turtle_bar: true, + turtle_foo: "yop", + partner_ids: [], + }, { + id: 2, + display_name: "donatello", + turtle_bar: true, + turtle_foo: "blip", + turtle_int: 9, + partner_ids: [2,4], + }, { + id: 3, + display_name: "raphael", + turtle_bar: false, + turtle_foo: "kawa", + turtle_int: 21, + turtle_qux: 9.8, + partner_ids: [], + }], + }, + user: { + fields: { + name: {string: "Name", type: "char"} + }, + records: [{ + id: 17, + name: "Aline", + }, { + id: 19, + name: "Christine", + }] + }, + }; + } +}, function () { + + QUnit.module('FieldMany2One'); + + QUnit.test('many2ones in form views', function (assert) { + assert.expect(4); + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="trululu"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'get_formview_action') { + assert.deepEqual(args.args[0], [4], "should call get_formview_action with correct id"); + return $.when({ + res_id: 17, + type: 'ir.actions.act_window', + target: 'current', + res_model: 'res.partner' + }); + } + if (args.method === 'get_formview_id') { + assert.deepEqual(args.args[0], [4], "should call get_formview_id with correct id"); + return $.when(false); + } + return this._super(route, args); + }, + }); + + testUtils.intercept(form, 'do_action', function (event) { + assert.strictEqual(event.data.action.res_id, 17, + "should do a do_action with correct parameters"); + }); + testUtils.intercept(form, 'load_views', function (event) { + // temporarily prevent the fields_view from being loaded (to prevent a crash) + // TODO: specify a fields_view and test that we can edit the record in the dialog + event.stopPropagation(); + }); + + assert.strictEqual(form.$('a.o_form_uri:contains(aaa)').length, 1, + "should contain a link"); + form.$('a.o_form_uri').click(); // click on the link in readonly mode (should trigger do_action) + + form.$buttons.find('.o_form_button_edit').click(); + + form.$('.o_external_button').click(); // click on the external button (should do an RPC) + + // TODO: test that we can edit the record in the dialog, and that the value is correctly + // updated on close + form.destroy(); + }); + + QUnit.test('many2one readonly fields with option "no_open"', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="trululu" options="{"no_open": True}" />' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('a.o_form_uri').length, 0, "should not have an anchor"); + form.destroy(); + }); + + QUnit.test('many2one in edit mode', function (assert) { + assert.expect(16); + + // create 10 partners to have the 'Search More' option in the autocomplete dropdown + for (var i=0; i<10; i++) { + var id = 20 + i; + this.data.partner.records.push({id: id, display_name: "Partner " + id}); + } + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="trululu"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + archs: { + 'partner,false,list': '<tree string="Partners"><field name="display_name"/></tree>', + 'partner,false,search': '<search string="Partners">' + + '<field name="display_name" string="Name"/>' + + '</search>', + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/write') { + assert.strictEqual(args.args[1].trululu, 20, "should write the correct id"); + } + return this._super.apply(this, arguments); + }, + }); + + // the SelectCreateDialog requests the session, so intercept its custom + // event to specify a fake session to prevent it from crashing + testUtils.intercept(form, 'get_session', function (event) { + event.data.callback({user_context: {}}); + }); + + form._toEditMode(); + var $dropdown = form.$('.o_form_field_many2one input').autocomplete('widget'); + + form.$('.o_form_field_many2one input').click(); + assert.ok($dropdown.is(':visible'), + 'clicking on the m2o input should open the dropdown if it is not open yet'); + assert.strictEqual($dropdown.find('li:not(.o_m2o_dropdown_option)').length, 7, + 'autocomplete should contains 7 suggestions'); + assert.strictEqual($dropdown.find('li.o_m2o_dropdown_option').length, 2, + 'autocomplete should contain "Search More" and Create and Edit..."'); + + form.$('.o_form_field_many2one input').click(); + assert.ok(!$dropdown.is(':visible'), + 'clicking on the m2o input should close the dropdown if it is open'); + + // change the value of the m2o with a suggestion of the dropdown + form.$('.o_form_field_many2one input').click(); + $dropdown.find('li:first()').click(); + assert.ok(!$dropdown.is(':visible'), 'clicking on a value should close the dropdown'); + assert.strictEqual(form.$('.o_form_field_many2one input').val(), 'first record', + 'value of the m2o should have been correctly updated'); + + // change the value of the m2o with a record in the 'Search More' modal + form.$('.o_form_field_many2one input').click(); + // click on 'Search More' (mouseenter required by ui-autocomplete) + $dropdown.find('.o_m2o_dropdown_option:contains(Search)').mouseenter().click(); + assert.ok($('.modal .o_list_view').length, "should have opened a list view in a modal"); + assert.ok(!$('.modal .o_list_view .o_list_record_selector').length, + "there should be no record selector in the list view"); + assert.ok(!$('.modal .modal-footer .o_select_button').length, + "there should be no 'Select' button in the footer"); + assert.ok($('.modal tbody tr').length > 10, "list should contain more than 10 records"); + // filter the list using the searchview + $('.modal .o_searchview_input').trigger({type: 'keypress', which: 80}); // P + $('.modal .o_searchview_input').trigger({type: 'keydown', which: 13}); // enter + assert.strictEqual($('.modal tbody tr').length, 10, + "list should be restricted to records containing a P (10 records)"); + // choose a record + $('.modal tbody tr:contains(Partner 20)').click(); // choose record 'Partner 20' + assert.ok(!$('.modal').length, "should have closed the modal"); + assert.ok(!$dropdown.is(':visible'), 'should have closed the dropdown'); + assert.strictEqual(form.$('.o_form_field_many2one input').val(), 'Partner 20', + 'value of the m2o should have been correctly updated'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('a.o_form_uri').text(), 'Partner 20', + "should display correct value after save"); + + form.destroy(); + }); + + QUnit.test('many2one field with option always_reload', function (assert) { + assert.expect(4); + var count = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="trululu" options="{\'always_reload\': True}"/>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'name_get') { + count++; + return $.when([[1, "first record\nand some address"]]); + } + return this._super(route, args); + }, + }); + + assert.strictEqual(count, 1, "an extra name_get should have been done"); + assert.ok(form.$('a:contains(and some address)').length, + "should display additional result"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('input').val(), "first record", + "actual field value should be displayed to be edited"); + + form.$buttons.find('.o_form_button_save').click(); + + assert.ok(form.$('a:contains(and some address)').length, + "should still display additional result"); + form.destroy(); + }); + + // QUnit.test('onchange on a many2one to a different model', function (assert) { + // This test is commented because the mock server does not give the correct response. + // It should return a couple [id, display_name], but I don't know the logic used + // by the server, so it's hard to emulate it correctly + // assert.expect(2); + + // this.data.partner.records[0].product_id = 41; + // this.data.partner.onchanges = { + // foo: function(obj) { + // obj.product_id = 37; + // }, + // }; + + // var form = createView({ + // View: FormView, + // model: 'partner', + // data: this.data, + // arch: '<form>' + + // '<field name="foo"/>' + + // '<field name="product_id"/>' + + // '</form>', + // res_id: 1, + // }); + // form.$buttons.find('.o_form_button_edit').click(); + // assert.strictEqual(form.$('input').eq(1).val(), 'xpad', "initial product_id val should be xpad"); + + // form.$('input').eq(0).val("let us trigger an onchange").trigger('input'); + + // assert.strictEqual(form.$('input').eq(1).val(), 'xphone', "onchange should have been applied"); + // }); + + QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="product_id"/>' + + '</form>', + res_id: 1, + viewOptions: { + domain: [['trululu', '=', 4]] + }, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.deepEqual(args.kwargs.args, [], "should not have a domain"); + } + return this._super(route, args); + } + }); + form.$buttons.find('.o_form_button_edit').click(); + + form.$('input').click(); + form.destroy(); + }); + + QUnit.test('autocompletion in a many2one, in form view with a date field', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="bar"/>' + + '<field name="date"/>' + + '<field name="trululu" domain="[(\'bar\',\'=\',True)]"/>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.deepEqual(args.kwargs.args, [["bar", "=", true]], "should not have a domain"); + } + return this._super(route, args); + }, + }); + form.$buttons.find('.o_form_button_edit').click(); + + form.$('input:eq(2)').click(); + form.destroy(); + }); + + QUnit.test('creating record with many2one with option always_reload', function (assert) { + assert.expect(2); + + this.data.partner.fields.trululu.default = 1; + this.data.partner.onchanges = { + trululu: function (obj) { + obj.trululu = 2; //[2, "second record"]; + }, + }; + + var count = 0; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="trululu" options="{\'always_reload\': True}"/>' + + '</form>', + mockRPC: function (route, args) { + count++; + if (args.method === 'name_get' && args.args[0][0] === 2) { + return $.when([[2, "hello world\nso much noise"]]); + } + return this._super(route, args); + }, + }); + + assert.strictEqual(count, 3, "should have done 3 rpcs (default_get, onchange, name_get)"); + assert.strictEqual(form.$('input').val(), 'hello world', + "should have taken the correct display name"); + form.destroy(); + }); + + QUnit.test('selecting a many2one, then discarding', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="product_id"/>' + + '</form>', + res_id: 1, + }); + assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty'); + form.$buttons.find('.o_form_button_edit').click(); + + form.$('.o_form_field_many2one input').click(); + form.$('.o_form_field_many2one input').autocomplete('widget').find('a').first().click(); + + + assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone"); + + form.$buttons.find('.o_form_button_cancel').click(); + assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty'); + form.destroy(); + }); + + QUnit.test('domain and context are correctly used when doing a name_search in a m2o', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<field name="product_id" ' + + 'domain="[[\'foo\', \'=\', \'bar\'], [\'foo\', \'=\', foo]]" ' + + 'context="{\'hello\': \'world\', \'test\': foo}"/>' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + session: {user_context: {hey: "ho"}}, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/product/name_search') { + assert.deepEqual( + args.kwargs.args, + [['foo', '=', 'bar'], ['foo', '=', 'yop']], + 'the field attr domain should have been used for the RPC (and evaluated)'); + assert.deepEqual( + args.kwargs.context, + {hey: "ho", hello: "world", test: "yop"}, + 'the field attr context should have been used for the ' + + 'RPC (evaluated and merged with the session one)'); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('input').first().click(); + + form.destroy(); + }); + + QUnit.module('FieldOne2Many'); + + QUnit.test('one2many basic properties', function (assert) { + assert.expect(6); + + this.data.partner.records[0].p = [2]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="Partner page">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + + assert.strictEqual(form.$('td.o_list_record_selector').length, 0, + "embedded one2many should not have a selector"); + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + "embedded one2many should not be editable"); + assert.ok(!form.$('td.o_list_record_delete').length, + "embedded one2many records should not have a trash icon"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(form.$('.o_form_field_x2many_list_row_add').length, + "embedded one2many should now be editable"); + + assert.strictEqual(form.$('.o_form_field_x2many_list_row_add').attr('colspan'), "2", + "should have colspan 2 (one for field foo, one for being below trash icon)"); + + assert.ok(form.$('td.o_list_record_delete').length, + "embedded one2many records should have a trash icon"); + form.destroy(); + }); + + QUnit.test('one2many with date and datetime', function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = [2]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="Partner page">' + + '<field name="p">' + + '<tree>' + + '<field name="date"/>' + + '<field name="datetime"/>' + + '</tree>' + + '</field>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + // FIXME: this test is locale dependant. we need to do it right. + assert.strictEqual(form.$('td:contains(01/25/2017)').length, 1, + "should have formatted the date"); + assert.strictEqual(form.$('td:contains(12/12/2016 10:55:05)').length, 1, + "should have formatted the datetime"); + form.destroy(); + }); + + QUnit.test('rendering with embedded one2many', function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = [2]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="P page">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('th:contains(Foo)').length, 1, + "embedded one2many should have a column titled according to foo"); + assert.strictEqual(form.$('td:contains(blip)').length, 1, + "embedded one2many should have a cell with relational value"); + form.destroy(); + }); + + QUnit.test('embedded one2many with widget', function (assert) { + assert.expect(1); + + this.data.partner.records[0].p = [2]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="P page">' + + '<field name="p">' + + '<tree>' + + '<field name="int_field" widget="handle"/>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('span.o_row_handle').length, 1, "should have 1 handles"); + form.destroy(); + }); + + QUnit.test('one2many field when using the pager', function (assert) { + assert.expect(13); + + var ids = []; + for (var i=0; i<45; i++) { + var id = 10 + i; + ids.push(id); + this.data.partner.records.push({ + id: id, + display_name: "relational record " + id, + }); + } + this.data.partner.records[0].p = ids.slice(0, 42); + this.data.partner.records[1].p = ids.slice(42); + + var count = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<kanban>' + + '<field name="display_name"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div><t t-esc="record.display_name"/></div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '</field>' + + '</form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + mockRPC: function () { + count++; + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + + // we are on record 1, which has 90 related record (first 40 should be + // displayed), 2 RPCs (read) should have been done, one on the main record + // and one for the O2M + assert.strictEqual(count, 2, 'two RPCs should have been done'); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'one2many kanban should contain 40 cards for record 1'); + + // move to record 2, which has 3 related records (and shouldn't contain the + // related records of record 1 anymore). Two additional RPCs should have + // been done + form.pager.next(); + assert.strictEqual(count, 4, 'two RPCs should have been done'); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 3, + 'one2many kanban should contain 3 cards for record 2'); + + // move back to record 1, which should contain again its first 40 related + // records + form.pager.previous(); + assert.strictEqual(count, 6, 'two RPCs should have been done'); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'one2many kanban should contain 40 cards for record 1'); + + // move to the second page of the o2m: 1 RPC should have been done to fetch + // the 2 subrecords of page 2, and those records should now be displayed + form.$('.o_x2m_control_panel .o_pager_next').click(); + assert.strictEqual(count, 7, 'one RPC should have been done'); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 2, + 'one2many kanban should contain 2 cards for record 1 at page 2'); + + // move to record 2 again and check that everything is correctly updated + form.pager.next(); + assert.strictEqual(count, 9, 'two RPCs should have been done'); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 3, + 'one2many kanban should contain 3 cards for record 2'); + + // move back to record 1 and move to page 2 again: all data should have + // been correctly reloaded + form.pager.previous(); + assert.strictEqual(count, 11, 'two RPCs should have been done'); + form.$('.o_x2m_control_panel .o_pager_next').click(); + assert.strictEqual(count, 12, 'one RPC should have been done'); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 2, + 'one2many kanban should contain 2 cards for record 1 at page 2'); + form.destroy(); + }); + + QUnit.test('sorting one2many fields', function (assert) { + assert.expect(4); + + this.data.partner.fields.foo.sortable = true; + this.data.partner.records.push({id: 23, foo: "abc"}); + this.data.partner.records.push({id: 24, foo: "xyz"}); + this.data.partner.records.push({id: 25, foo: "def"}); + this.data.partner.records[0].p = [23,24,25]; + + var rpcCount = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + + rpcCount = 0; + assert.ok(form.$('table tbody tr:eq(2) td:contains(def)').length, + "the 3rd record is the one with 'def' value"); + form.renderer._render = function () { + throw "should not render the whole form"; + }; + + form.$('table thead th:contains(Foo)').click(); + assert.strictEqual(rpcCount, 0, + 'sort should be in memory, no extra RPCs should have been done'); + assert.ok(form.$('table tbody tr:eq(2) td:contains(xyz)').length, + "the 3rd record is the one with 'xyz' value"); + + form.$('table thead th:contains(Foo)').click(); + assert.ok(form.$('table tbody tr:eq(2) td:contains(abc)').length, + "the 3rd record is the one with 'abc' value"); + + form.destroy(); + }); + + QUnit.test('one2many list field edition', function (assert) { + assert.expect(6); + + this.data.partner.records.push({ + id: 3, + display_name: "relational record 1", + }); + this.data.partner.records[1].p = [3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 2, + }); + + // edit the first line of the o2m + assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'relational record 1', + "display name of first record in o2m list should be 'relational record 1'"); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_one2many tbody td').first().click(); + assert.ok(form.$('.o_field_one2many tbody td').first().hasClass('o_edit_mode'), + "first row of o2m should be in edition"); + form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input'); + assert.ok(form.$('.o_field_one2many tbody td').first().hasClass('o_edit_mode'), + "first row of o2m should still be in edition"); + + // // leave o2m edition + form.$el.click(); + assert.ok(!form.$('.o_field_one2many tbody td').first().hasClass('o_edit_mode'), + "first row of o2m should be readonly again"); + + // discard changes + form.$buttons.find('.o_form_button_cancel').click(); + assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value', + "changes shouldn't have been discarded yet, waiting for user confirmation"); + $('.modal .modal-footer .btn-primary').click(); + assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'relational record 1', + "display name of first record in o2m list should be 'relational record 1'"); + + // edit again and save + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_one2many tbody td').first().click(); + form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input'); + form.$el.click(); + form.$buttons.find('.o_form_button_save').click(); + // FIXME: this next test doesn't pass as the save of updates of + // relational data is temporarily disabled + // assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value', + // "display name of first record in o2m list should be 'new value'"); + + form.destroy(); + }); + + QUnit.test('one2many list: create action disabled', function (assert) { + assert.expect(2); + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p">' + + '<tree create="0">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" link should not be available in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" link should not be available in readonly'); + form.destroy(); + }); + + QUnit.test('one2many list: deleting one record', function (assert) { + assert.expect(5); + this.data.partner.records[0].p = [2, 4]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/write') { + var commands = args.args[1].p; + assert.strictEqual(commands.length, 2, + 'should have generated two commands'); + assert.ok(commands[0][0] === 4 && commands[0][1] === 4, + 'should have generated the command 4 (LINK_TO) with id 4'); + assert.ok(commands[1][0] === 2 && commands[1][1] === 2, + 'should have generated the command 2 (DELETE) with id 2'); + } + return this._super.apply(this, arguments); + }, + }); + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('td.o_list_record_delete span').length, 2, + "should have 2 delete buttons"); + + form.$('td.o_list_record_delete span').first().click(); + + assert.strictEqual(form.$('td.o_list_record_delete span').length, 1, + "should have 1 delete button (a record is supposed to have been deleted)"); + + // save and check that the correct command has been generated + form.$buttons.find('.o_form_button_save').click(); + + // FIXME: it would be nice to test that the view is re-rendered correctly, + // but as the relational data isn't re-fetched, the rendering is ok even + // if the changes haven't been saved + form.destroy(); + }); + + QUnit.test('one2many kanban: edition', function (assert) { + assert.expect(14); + + this.data.partner.records[0].p = [2]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<kanban>' + + // color will be in the kanban but not in the form + '<field name="color"/>' + + '<field name="display_name"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<a t-if="!read_only_mode" type="delete" class="fa fa-times pull-right delete_icon"/>' + + '<span><t t-esc="record.display_name.value"/></span>' + + '<span><t t-esc="record.color.value"/></span>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + // foo will be in the form but not in the kanban + '<field name="foo"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.ok(!form.$('.o_kanban_view .delete_icon').length, + 'delete icon should not be visible in readonly'); + assert.ok(!form.$('.o_form_field_one2many .o-kanban-button-new').length, + '"Create" button should not be visible in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1, + 'should contain 1 record'); + assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'second record', + 'display_name of subrecord should be the one in DB'); + assert.strictEqual(form.$('.o_kanban_record span:nth(1)').text(), 'Red', + 'color of subrecord should be the one in DB'); + assert.ok(form.$('.o_kanban_view .delete_icon').length, + 'delete icon should be visible in edit'); + assert.ok(form.$('.o_form_field_one2many .o-kanban-button-new').length, + '"Create" button should be visible in edit'); + + // edit existing subrecord + form.$('.oe_kanban_global_click').click(); + + $('.modal .o_form_view .o_form_input').val('new name').trigger('input'); + $('.modal .modal-footer .btn-primary').click(); // save + assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'new name', + 'value of subrecord should have been updated'); + + // create a new subrecord + form.$('.o-kanban-button-new').click(); + $('.modal .o_form_view .o_form_input').val('new subrecord 1').trigger('input'); + $('.modal .modal-footer .btn-primary').click(); // save and close + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2, + 'should contain 2 records'); + assert.strictEqual(form.$('.o_kanban_record:nth(1) span').text(), 'new subrecord 1', + 'value of newly created subrecord should be "new subrecord 1"'); + + // create two new subrecords + form.$('.o-kanban-button-new').click(); + $('.modal .o_form_view .o_form_input').val('new subrecord 2').trigger('input'); + $('.modal .modal-footer .btn-primary:nth(1)').click(); // save and new + $('.modal .o_form_view .o_form_input').val('new subrecord 3').trigger('input'); + $('.modal .modal-footer .btn-primary').click(); // save and close + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 4, + 'should contain 4 records'); + + // delete subrecords + form.$('.o_kanban_view .delete_icon:first()').click(); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 3, + 'should contain 3 records'); + form.$('.o_kanban_view .delete_icon:first()').click(); + form.$('.o_kanban_view .delete_icon:first()').click(); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1, + 'should contain 1 records'); + assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'new subrecord 3', + 'the remaining subrecord should be "new subrecord 3"'); + form.destroy(); + }); + + QUnit.test('one2many kanban: create action disabled', function (assert) { + assert.expect(3); + + this.data.partner.records[0].p = [4]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p">' + + '<kanban create="0">' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<a t-if="!read_only_mode" type="delete" class="fa fa-times pull-right delete_icon"/>' + + '<span><t t-esc="record.display_name.value"/></span>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.ok(!form.$('.o-kanban-button-new').length, + '"Add" button should not be available in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(!form.$('.o-kanban-button-new').length, + '"Add" button should not be available in edit'); + assert.ok(form.$('.o_kanban_view .delete_icon').length, + 'delete icon should be visible in edit'); + form.destroy(); + }); + + QUnit.test('one2many list (non editable): edition', function (assert) { + assert.expect(12); + + var nbWrite = 0; + this.data.partner.records[0].p = [2, 4]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/><field name="qux"/>' + + '</tree>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + nbWrite++; + assert.deepEqual(args.args[1], { + p: [[1, 2, {display_name: 'new name'}], [2, 4, false]] + }, "should have sent the correct commands"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.ok(!form.$('.o_list_record_delete').length, + 'delete icon should not be visible in readonly'); + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" should not be visible in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2, + 'should contain 2 records'); + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'second record', + 'display_name of first subrecord should be the one in DB'); + assert.ok(form.$('.o_list_record_delete').length, + 'delete icon should be visible in edit'); + assert.ok(form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" should not visible in edit'); + + // edit existing subrecord + form.$('.o_list_view tbody tr:first() td:eq(1)').click(); + + $('.modal .o_form_view .o_form_input').val('new name').trigger('input'); + $('.modal .modal-footer .btn-primary').click(); // save + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name', + 'value of subrecord should have been updated'); + assert.strictEqual(nbWrite, 0, "should not have write anything in DB"); + + // create new subrecords + // TODO when 'Add an item' will be implemented + + // delete subrecords + form.$('.o_list_record_delete:nth(1)').click(); + assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 1, + 'should contain 1 subrecord'); + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name', + 'the remaining subrecord should be "new name"'); + + form.$buttons.find('.o_form_button_save').click(); // save the record + assert.strictEqual(nbWrite, 1, "should have write the changes in DB"); + + form.destroy(); + }); + + QUnit.test('one2many list (editable): edition', function (assert) { + assert.expect(7); + + this.data.partner.records[0].p = [2, 4]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/><field name="qux"/>' + + '</tree>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" link should not be available in readonly'); + + form.$('.o_list_view tbody td:first()').click(); + assert.ok($('.modal .o_form_readonly').length, + 'in readonly, clicking on a subrecord should open it in readonly in a dialog'); + $('.modal .o_form_button_cancel').click(); // close the dialog + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" link should be available in edit'); + + // edit existing subrecord + form.$('.o_list_view tbody td:first()').click(); + assert.strictEqual($('.modal').length, 0, + 'in edit, clicking on a subrecord should not open a dialog'); + assert.ok(form.$('.o_list_view tbody tr:first()').hasClass('o_selected_row'), + 'first row should be in edition'); + form.$('.o_list_view .o_form_input:first()').val('new name').trigger('input'); + + form.$('.o_list_view tbody tr:nth(1) td:first').click(); // click on second record to validate the first one + assert.ok(!form.$('.o_list_view tbody tr:first').hasClass('o_selected_row'), + 'first row should not be in edition anymore'); + assert.strictEqual(form.$('.o_list_view tbody td:first').text(), 'new name', + 'value of subrecord should have been updated'); + + // create new subrecords + // TODO when 'Add an item' will be implemented + form.destroy(); + }); + + QUnit.test('one2many list (editable): edition, part 2', function (assert) { + assert.expect(8); + + var form = 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: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].p[0][0], 0, + "should send a 0 command for field p"); + assert.strictEqual(args.args[1].p[1][0], 0, + "should send a second 0 command for field p"); + } + return this._super.apply(this, arguments); + }, + }); + + // edit mode, then click on Add an item and enter a value + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + form.$('td.o_edit_mode input').val('kartoffel').trigger('input'); + + // click again on Add an item + form.$('.o_form_field_x2many_list_row_add a').click(); + assert.strictEqual(form.$('td:contains(kartoffel)').length, 1, + "should have one td with the new value"); + assert.strictEqual(form.$('td.o_edit_mode input').length, 1, + "should have one other new td"); + assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows"); + + // enter another value and save + form.$('td.o_edit_mode input').val('gemuse').trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows"); + assert.strictEqual(form.$('td:contains(kartoffel)').length, 1, + "should have one td with the new value"); + assert.strictEqual(form.$('td:contains(gemuse)').length, 1, + "should have one td with the new value"); + + form.destroy(); + }); + + QUnit.test('one2many list (editable): edition, part 3', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 1, + }); + + // edit mode, then click on Add an item 2 times + assert.strictEqual(form.$('tr.o_data_row').length, 1, + "should have 1 data rows"); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + assert.strictEqual(form.$('tr.o_data_row').length, 3, + "should have 3 data rows"); + + // cancel the edition + form.$buttons.find('.o_form_button_cancel').click(); + $('.modal-footer button.btn-primary').first().click(); + assert.strictEqual(form.$('tr.o_data_row').length, 1, + "should have 1 data rows"); + + form.destroy(); + }); + + QUnit.test('one2many list (editable): edition, part 4', function (assert) { + assert.expect(3); + + this.data.turtle.onchanges = { + turtle_trululu: function (obj) { + if (obj.turtle_trululu) { + obj.turtle_description = "Some Description"; + } + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_trululu"/>' + + '<field name="turtle_description"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + }); + + // edit mode, then click on Add an item + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should have 0 data rows"); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + assert.strictEqual(form.$('textarea').val(), "", + "field turtle_description should be empty"); + + // add a value in the turtle_trululu field to trigger an onchange + var $dropdown = form.$('.o_form_field_many2one[name=turtle_trululu] input') + .autocomplete('widget'); + form.$('.o_form_field_many2one[name=turtle_trululu] input').click(); + $dropdown.find('a:contains(first record)').mouseenter().click(); + assert.strictEqual(form.$('textarea').val(), "Some Description", + "field turtle_description should be set to the result of the onchange"); + form.destroy(); + }); + + QUnit.test('one2many list (editable): discarding required empty data', function (assert) { + assert.expect(7); + + this.data.turtle.fields.turtle_foo.required = true; + delete this.data.turtle.fields.turtle_foo.default; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + }); + + // edit mode, then click on Add an item, then click elsewhere + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should have 0 data rows"); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + form.$('label.o_form_label').first().click(); + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should still have 0 data rows"); + + // click on Add an item again, then click on save + form.$('.o_form_field_x2many_list_row_add a').click(); + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should still have 0 data rows"); + + assert.verifySteps(['read', 'default_get', 'default_get']); + form.destroy(); + }); + + QUnit.test('unselecting a line with missing required data', function (assert) { + assert.expect(5); + + this.data.turtle.fields.turtle_foo.required = true; + delete this.data.turtle.fields.turtle_foo.default; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_foo"/>' + + '<field name="turtle_int"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + }); + + // edit mode, then click on Add an item, then click elsewhere + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should have 0 data rows"); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + assert.strictEqual(form.$('tr.o_data_row').length, 1, + "should have 1 data rows"); + + // adding a value in the non required field, so it is dirty, but with + // a missing required field + form.$('input[name="turtle_int"]').val('12345').trigger('input'); + + // click elsewhere, + form.$('label.o_form_label').click(); + assert.strictEqual($('.modal').length, 1, + 'a confirmation model should be opened'); + + // click on cancel, the line should still be selected + $('.modal .modal-footer button.btn-default').click(); + assert.strictEqual(form.$('tr.o_data_row.o_selected_row').length, 1, + "should still have 1 selected data row"); + + // click elsewhere, and click on ok (on the confirmation dialog) + form.$('label.o_form_label').click(); + $('.modal .modal-footer button.btn-primary').click(); + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should have 0 data rows (invalid line has been discarded"); + + form.destroy(); + }); + + QUnit.test('editing a o2m, with required field and onchange', function (assert) { + assert.expect(12); + + this.data.turtle.fields.turtle_foo.required = true; + delete this.data.turtle.fields.turtle_foo.default; + this.data.turtle.onchanges = { + turtle_foo: function (obj) { + obj.turtle_int = obj.turtle_foo.length; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_foo"/>' + + '<field name="turtle_int"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method) { + assert.step(args.method); + } + return this._super.apply(this, arguments); + }, + }); + + // edit mode, then click on Add an item + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should have 0 data rows"); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + + // input some text in required turtle_foo field + form.$('input[name="turtle_foo"]').val('aubergine').trigger('input'); + assert.strictEqual(form.$('input[name="turtle_int"]').val(), "9", + "onchange should have been triggered"); + + // save and check everything is fine + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_data_row td:contains(aubergine)').length, 1, + "should have one row with turtle_foo value"); + assert.strictEqual(form.$('.o_data_row td:contains(9)').length, 1, + "should have one row with turtle_int value"); + + assert.verifySteps(['read', 'default_get', 'onchange', 'onchange', 'write', 'read', 'read']); + form.destroy(); + }); + + QUnit.test('onchange in a one2many', function (assert) { + assert.expect(1); + + this.data.partner.records.push({ + id: 3, + foo: "relational record 1", + }); + this.data.partner.records[1].p = [3]; + this.data.partner.onchanges = {p: true}; + + var form = 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 $.when({value: { p: [ + [5], // delete all + [0, 0, {foo: "from onchange"}], // create new + ]}}); + } + return this._super(route, args); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_one2many tbody td').first().click(); + form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'from onchange', + "display name of first record in o2m list should be 'new value'"); + form.destroy(); + }); + + QUnit.test('one2many, default_get and onchange (basic)', function (assert) { + assert.expect(1); + + this.data.partner.fields.p.default = [ + [6, 0, []], // replace with zero ids + ]; + this.data.partner.onchanges = {p: true}; + + var form = 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) { + if (args.method === 'onchange') { + return $.when({value: { p: [ + [5], // delete all + [0, 0, {foo: "from onchange"}], // create new + ]}}); + } + return this._super(route, args); + }, + }); + + assert.ok(form.$('td:contains(from onchange)').length, + "should have 'from onchange' value in one2many"); + form.destroy(); + }); + + QUnit.test('one2many list (editable): readonly domain is evaluated', function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = [2, 4]; + this.data.partner.records[1].product_id = false; + this.data.partner.records[2].product_id = 37; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name" attrs=\'{"readonly": [["product_id", "=", false]]}\'/>' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(form.$('.o_list_view tbody tr:eq(0) td:first').hasClass('o_readonly'), + "first record should have display_name in readonly mode"); + + assert.notOk(form.$('.o_list_view tbody tr:eq(1) td:first').hasClass('o_readonly'), + "second record should not have display_name in readonly mode"); + form.destroy(); + }); + + QUnit.test('pager of one2many field in new record', function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = []; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + archs: { + 'partner,false,form': + '<form string="Partner"><field name="foo"/></form>', + }, + }); + + assert.ok(!form.$('.o_x2m_control_panel .o_cp_pager div').is(':visible'), + 'o2m pager should be hidden'); + + // click to create a subrecord + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + $('.modal .o_form_input').val('new record').trigger('input'); + $('.modal .modal-footer button:eq(0)').click(); // save and close + + assert.ok(!form.$('.o_x2m_control_panel .o_cp_pager div').is(':visible'), + 'o2m pager should be hidden'); + form.destroy(); + }); + + QUnit.test('one2many list with a many2one', function (assert) { + assert.expect(5); + + this.data.partner.records[0].p = [2]; + this.data.partner.records[1].product_id = 37; + this.data.partner.onchanges.p = function (obj) { + obj.p = [ + [5], // delete all + [1, 2, {product_id: [37, "xphone"]}], // update existing record + [0, 0, {product_id: [41, "xpad"]}] + ]; + // + }; + + var form = 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, + archs: { + 'partner,false,form': + '<form string="Partner"><field name="product_id"/></form>', + }, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.deepEqual(args.args[1].p, [[4, 2, false], [0, false, {product_id: 41}]], + "should trigger onchange with correct parameters"); + } + return this._super.apply(this, arguments); + } + }); + + assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1, + "should have properly fetched the many2one nameget"); + assert.strictEqual(form.$('tbody td:contains(xpad)').length, 0, + "should not display 'xpad' anywhere"); + + form.$buttons.find('.o_form_button_edit').click(); + + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + + $('.modal .o_form_field_many2one input').click(); + + var $dropdown = $('.modal .o_form_field_many2one input').autocomplete('widget'); + + $dropdown.find('li:eq(1) a').mouseenter(); + $dropdown.find('li:eq(1) a').click(); + + $('.modal .modal-footer button:eq(0)').click(); // save and close + + assert.strictEqual(form.$('tbody td:contains(xpad)').length, 1, + "should display 'xpad' on a td"); + assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1, + "should still display xphone"); + form.destroy(); + }); + + QUnit.test('one2many list with inline form view', function (assert) { + assert.expect(5); + + this.data.partner.records[0].p = []; + + var rpcCount = 0; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<form string="Partner">' + + '<field name="product_id"/>' + + '<field name="int_field"/>' + + '</form>' + + '<tree>' + + '<field name="product_id"/>' + + '<field name="foo"/>' + // don't remove this, it is + // useful to make sure the foo fieldwidget + // does not crash because the foo field + // is not in the form view + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + rpcCount++; + if (args.method === 'write') { + assert.deepEqual(args.args[1].p, [[0, false, { + display_name: false, int_field: 123, product_id: 41 + }]]); + } + return this._super(route, args); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + + // write in the many2one field, value = 37 (xphone) + $('.modal .o_form_field_many2one input').click(); + var $dropdown = $('.modal .o_form_field_many2one input').autocomplete('widget'); + $dropdown.find('li:eq(0) a').mouseenter(); + $dropdown.find('li:eq(0) a').click(); + + // write in the integer field + $('.modal .modal-body input.o_field_widget').val('123').trigger('input'); + + // save and close + $('.modal .modal-footer button:eq(0)').click(); + + assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1, + "should display 'xphone' in a td"); + + // reopen the record in form view + form.$('tbody td:contains(xphone)').click(); + + assert.strictEqual($('.modal .modal-body input').val(), "xphone", + "should display 'xphone' in an input"); + + $('.modal .modal-body input.o_field_widget').val('456').trigger('input'); + + // discard + $('.modal .modal-footer span:contains(Discard)').click(); + + // reopen the record in form view + form.$('tbody td:contains(xphone)').click(); + + assert.strictEqual($('.modal .modal-body input.o_field_widget').val(), "123", + "should display 123 (previous change has been discarded)"); + + // write in the many2one field, value = 41 (xpad) + $('.modal .o_form_field_many2one input').click(); + $dropdown = $('.modal .o_form_field_many2one input').autocomplete('widget'); + $dropdown.find('li:eq(1) a').mouseenter(); + $dropdown.find('li:eq(1) a').click(); + + // save and close + $('.modal .modal-footer button:eq(0)').click(); + + assert.strictEqual(form.$('tbody td:contains(xpad)').length, 1, + "should display 'xpad' in a td"); + + // save the record + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); + + QUnit.test('one2many list with inline form view with context with parent key', function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = [2]; + this.data.partner.records[0].product_id = 41; + this.data.partner.records[1].product_id = 37; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<field name="product_id"/>' + + '<field name="p">' + + '<form string="Partner">' + + '<field name="product_id" context="{\'partner_foo\':parent.foo, \'lalala\': parent.product_id}"/>' + + '</form>' + + '<tree>' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.strictEqual(args.kwargs.context.partner_foo, "yop", + "should have correctly evaluated parent foo field"); + assert.strictEqual(args.kwargs.context.lalala, 41, + "should have correctly evaluated parent product_id field"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + // open a modal + form.$('tr.o_data_row:eq(0) td:contains(xphone)').click(); + + // write in the many2one field + $('.modal .o_form_field_many2one input').click(); + + form.destroy(); + }); + + QUnit.test('one2many list, editable, with many2one and with context with parent key', function (assert) { + assert.expect(1); + + this.data.partner.records[0].p = [2]; + this.data.partner.records[1].product_id = 37; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="product_id" context="{\'partner_foo\':parent.foo}"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.strictEqual(args.kwargs.context.partner_foo, "yop", + "should have correctly evaluated parent foo field"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + form.$('tr.o_data_row:eq(0) td:contains(xphone)').click(); + + // trigger a name search + form.$('table td input.o_form_input').click(); + + form.destroy(); + }); + + QUnit.test('one2many list edition, some basic functionality', function (assert) { + assert.expect(3); + + this.data.partner.fields.foo.default = false; + + var form = 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: 1, + }); + form.$buttons.find('.o_form_button_edit').click(); + + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + + assert.strictEqual(form.$('td input.o_field_widget.o_form_input').length, 1, + "should have created a row in edit mode"); + + form.$('td input.o_field_widget.o_form_input').val('a').trigger('input'); + + assert.strictEqual(form.$('td input.o_field_widget.o_form_input').length, 1, + "should not have unselected the row after edition"); + + form.$('td input.o_field_widget.o_form_input').val('abc').trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('td:contains(abc)').length, 1, + "should have a row with the correct value"); + form.destroy(); + }); + + QUnit.test('one2many list, the context is properly evaluated and sent', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="p" context="{\'hello\': \'world\', \'abc\': int_field}">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'default_get') { + var context = args.kwargs.context; + assert.strictEqual(context.hello, "world"); + assert.strictEqual(context.abc, 10); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + form.destroy(); + }); + + QUnit.test('one2many with many2many widget: create', function (assert) { + assert.expect(10); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="turtles" widget="many2many">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '<field name="turtle_qux"/>' + + '<field name="turtle_int"/>' + + '<field name="product_id"/>' + + '</tree>' + + '<form>' + + '<group>' + + '<field name="turtle_foo"/>' + + '<field name="turtle_bar"/>' + + '<field name="turtle_int"/>' + + '<field name="product_id"/>' + + '</group>' + + '</form>' + + '</field>' + + '</form>', + archs: { + 'turtle,false,list': '<tree><field name="display_name"/><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></tree>', + 'turtle,false,search': '<search><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></search>', + }, + session: {}, + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/turtle/create') { + assert.ok(args.args, "should write on the turtle record"); + } + if (route === '/web/dataset/call_kw/partner/write') { + assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1"); + assert.strictEqual(args.args[1].turtles[0][0], 6, "should send only a 'replace with' command"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + + assert.strictEqual($('.modal .o_data_row').length, 2, + "sould have 2 records in the select view (the last one is not displayed because it is already selected)"); + + $('.modal .o_data_row:first .o_list_record_selector input').click(); + $('.modal .o_select_button').click(); + $('.o_form_button_save').click(); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + + assert.strictEqual($('.modal .o_data_row').length, 1, + "sould have 1 record in the select view"); + + $('.modal-footer button:eq(1)').click(); + $('.modal input.o_form_field[name="turtle_foo"]').val('tototo').trigger('input'); + $('.modal input.o_form_field[name="turtle_int"]').val(50).trigger('input'); + var $many2one = $('.modal [name="product_id"] input').click(); + var $dropdown = $many2one.autocomplete('widget'); + $dropdown.find('li:first a').mouseenter(); + $dropdown.find('li:first a').click(); + + $('.modal-footer button:contains(&):first').click(); + + assert.strictEqual($('.modal').length, 0, "sould close the modals"); + + assert.strictEqual(form.$('.o_data_row').length, 3, + "sould have 3 records in one2many list"); + assert.strictEqual(form.$('.o_data_row').text(), "blip1.59yop1.5tototo1.550xphone", + "sould display the record values in one2many list"); + + $('.o_form_button_save').click(); + + form.destroy(); + }); + + QUnit.test('one2many with many2many widget: edition', function (assert) { + assert.expect(6); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="turtles" widget="many2many">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '<field name="turtle_qux"/>' + + '<field name="turtle_int"/>' + + '<field name="product_id"/>' + + '</tree>' + + '<form>' + + '<group>' + + '<field name="turtle_foo"/>' + + '<field name="turtle_bar"/>' + + '<field name="turtle_int"/>' + + '<field name="turtle_trululu"/>' + + '<field name="product_id"/>' + + '</group>' + + '</form>' + + '</field>' + + '</form>', + archs: { + 'turtle,false,list': '<tree><field name="display_name"/><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></tree>', + 'turtle,false,search': '<search><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></search>', + }, + session: {}, + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/turtle/write') { + assert.strictEqual(args.args[0].length, 1, "should write on the turtle record"); + assert.deepEqual(args.args[1], {"product_id":37}, "should write only the product_id on the turtle record"); + } + if (route === '/web/dataset/call_kw/partner/write') { + assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1"); + assert.strictEqual(args.args[1].turtles[0][0], 6, "should send only a 'replace with' command"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$('.o_data_row:first').click(); + $('.modal .o_form_button_cancel').click(); + form.$buttons.find('.o_form_button_edit').click(); + + // edit the first one2many record + form.$('.o_data_row:first').click(); + var $many2one = $('.modal [name="product_id"] input').click(); + var $dropdown = $many2one.autocomplete('widget'); + $dropdown.find('li:first a').mouseenter(); + $dropdown.find('li:first a').click(); + $('.modal-footer button:first').click(); + + $('.o_form_button_save').click(); // don't save anything because the one2many does not change + + // add a one2many record + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + $('.modal .o_data_row:first .o_list_record_selector input').click(); + $('.modal .o_select_button').click(); + + // edit the second one2many record + form.$('.o_data_row:eq(1)').click(); + $many2one = $('.modal [name="product_id"] input').click(); + $dropdown = $many2one.autocomplete('widget'); + $dropdown.find('li:first a').mouseenter(); + $dropdown.find('li:first a').click(); + $('.modal-footer button:first').click(); + + $('.o_form_button_save').click(); + + form.destroy(); + }); + + QUnit.test('new record, the context is properly evaluated and sent', function (assert) { + assert.expect(2); + + this.data.partner.fields.int_field.default = 17; + var n = 0; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="p" context="{\'hello\': \'world\', \'abc\': int_field}">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + n++; + if (n === 2) { + var context = args.kwargs.context; + assert.strictEqual(context.hello, "world"); + assert.strictEqual(context.abc, 17); + } + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + form.destroy(); + }); + + QUnit.test('parent data is properly sent on an onchange rpc', function (assert) { + assert.expect(1); + + this.data.partner.onchanges = {bar: function () {}}; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + var fieldValues = args.args[1]; + assert.strictEqual(fieldValues.trululu.foo, "yop", + "should have properly sent the parent foo value"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + form.destroy(); + }); + + QUnit.test('parent data is properly sent on an onchange rpc, new record', function (assert) { + assert.expect(1); + + this.data.partner.onchanges = {bar: function () {}}; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + var fieldValues = args.args[1]; + assert.strictEqual(fieldValues.trululu.foo, "My little Foo Value", + "should have properly sent the parent foo value"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + form.destroy(); + }); + + QUnit.test('sub form view with a required field', function (assert) { + assert.expect(2); + this.data.partner.fields.foo.required = true; + this.data.partner.fields.foo.default = null; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<form string="Partner">' + + '<group><field name="foo"/></group>' + + '</form>' + + '<tree>' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('tbody td.o_form_field_x2many_list_row_add a').click(); + $('.modal-footer button.btn-primary').first().click(); + + assert.strictEqual($('.modal').length, 1, "should still have an open modal"); + assert.strictEqual($('.modal tbody label.o_form_invalid').length, 1, + "should have displayed invalid fields"); + form.destroy(); + }); + + QUnit.test('one2many list with action button', function (assert) { + assert.expect(4); + + this.data.partner.records[0].p = [2]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '<button name="method_name" type="object" icon="fa-plus"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + intercepts: { + execute_action: function (event) { + assert.strictEqual(event.data.record_id, 2, + 'should call with correct id'); + assert.strictEqual(event.data.model, 'partner', + 'should call with correct model'); + assert.strictEqual(event.data.action_data.name, 'method_name', + "should call correct method"); + assert.strictEqual(event.data.action_data.type, 'object', + 'should have correct type'); + }, + }, + }); + + form.$('.o_list_button button').click(); + + form.destroy(); + }); + + QUnit.test('one2many kanban with action button', function (assert) { + assert.expect(4); + + this.data.partner.records[0].p = [2]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<kanban>' + + '<field name="foo"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div>' + + '<span><t t-esc="record.foo.value"/></span>' + + '<button name="method_name" type="object" class="fa fa-plus"/>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '</field>' + + '</form>', + res_id: 1, + intercepts: { + execute_action: function (event) { + assert.strictEqual(event.data.record_id, 2, + 'should call with correct id'); + assert.strictEqual(event.data.model, 'partner', + 'should call with correct model'); + assert.strictEqual(event.data.action_data.name, 'method_name', + "should call correct method"); + assert.strictEqual(event.data.action_data.type, 'object', + 'should have correct type'); + }, + }, + }); + + form.$('.oe_kanban_action_button').click(); + + form.destroy(); + }); + + QUnit.test('one2many without inline tree arch', function (assert) { + assert.expect(2); + + this.data.partner.records[0].turtles = [2,3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="p" widget="many2many_tags"/>' + // check if the view don not call load view (widget without useSubview) + '<field name="turtles"/>' + + '<field name="timmy" invisible="1"/>' + // check if the view don not call load view in invisible + '</group>' + + '</form>', + res_id: 1, + archs: { + "turtle,false,list": '<tree string="Turtles"><field name="turtle_bar"/><field name="display_name"/><field name="partner_ids"/></tree>', + } + }); + + assert.strictEqual(form.$('.o_form_field[name="turtles"] .o_list_view').length, 1, + 'should display one2many list view in the modal'); + + assert.strictEqual(form.$('.o_data_row').length, 2, + 'should display the 2 turtles'); + + form.destroy(); + }); + + QUnit.test('many2one and many2many in one2many', function (assert) { + assert.expect(11); + + this.data.turtle.records[1].product_id = 37; + this.data.partner.records[0].turtles = [2, 3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="int_field"/>' + + '<field name="turtles">' + + '<form string="Turtles">' + + '<group>' + + '<field name="product_id"/>' + + '</group>' + + '</form>' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '<field name="product_id"/>' + + '<field name="partner_ids" widget="many2many_tags"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + var commands = args.args[1].turtles; + assert.strictEqual(commands.length, 2, + "should have generated 2 commands"); + assert.deepEqual(commands[0], [1, 2, { + partner_ids: [[6, false, [2, 1]]], + product_id: 41, + }], "generated commands should be correct"); + assert.deepEqual(commands[1], [4, 3, false], + "generated commands should be correct"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 2, + 'should display the 2 turtles'); + assert.strictEqual(form.$('.o_data_row:first td:nth(1)').text(), 'xphone', + "should correctly display the m2o"); + assert.strictEqual(form.$('.o_data_row:first td:nth(2) .badge').length, 2, + "m2m should contain two tags"); + assert.strictEqual(form.$('.o_data_row:first td:nth(2) .badge:first span').text(), + 'second record', "m2m values should have been correctly fetched"); + + form.$('.o_data_row:first').click(); + + assert.strictEqual($('.modal .o_form_field').text(), "xphone", + 'should display the form view dialog with the many2one value'); + $('.modal-footer button').click(); // close the modal + + form.$buttons.find('.o_form_button_edit').click(); + + // edit the m2m of first row + form.$('.o_list_view tbody td:first()').click(); + // remove a tag + form.$('.o_form_field_many2manytags .badge:contains(aaa) .o_delete').click(); + assert.strictEqual(form.$('.o_selected_row .o_form_field_many2manytags .o_badge_text:contains(aaa)').length, 0, + "tag should have been correctly removed"); + // add a tag + var $m2mInput = form.$('.o_selected_row .o_form_field_many2manytags input'); + $m2mInput.click(); + $m2mInput.autocomplete('widget').find('li:first()').click(); + assert.strictEqual(form.$('.o_selected_row .o_form_field_many2manytags .o_badge_text:contains(first record)').length, 1, + "tag should have been correctly added"); + + // edit the m2o of first row + var $m2oInput = form.$('.o_selected_row .o_form_field_many2one:first input'); + $m2oInput.click(); + $m2oInput.autocomplete('widget').find('li:contains(xpad)').mouseover().click(); + assert.strictEqual(form.$('.o_selected_row .o_form_field_many2one:first input').val(), 'xpad', + "m2o value should have been updated"); + + // save (should correctly generate the commands) + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + }); + + QUnit.test('load view for x2many in one2many', function (assert) { + assert.expect(2); + + this.data.turtle.records[1].product_id = 37; + this.data.partner.records[0].turtles = [2,3]; + this.data.partner.records[2].turtles = [1,3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="int_field"/>' + + '<field name="turtles">' + + '<form string="Turtles">' + + '<group>' + + '<field name="product_id"/>' + + '<field name="partner_ids"/>' + + '</group>' + + '</form>' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 1, + archs: { + "partner,false,list": '<tree string="Partners"><field name="display_name"/></tree>', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 2, + 'should display the 2 turtles'); + + form.$('.o_data_row:first').click(); + + assert.strictEqual($('.modal .o_form_field[name="partner_ids"] .o_list_view').length, 1, + 'should display many2many list view in the modal'); + + form.destroy(); + }); + + QUnit.test('one2many (who contains a one2many) with tree view and without form view', function (assert) { + assert.expect(1); + + // avoid error in _postprocess + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree>' + + '<field name="partner_ids"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 1, + archs: { + "turtle,false,form": '<form string="Turtles"><field name="turtle_foo"/></form>', + }, + }); + + form.$('.o_data_row:first').click(); + + assert.strictEqual($('.modal .o_form_field[name="turtle_foo"]').text(), 'blip', + 'should open the modal and display the form field'); + + form.destroy(); + }); + + QUnit.test('one2many (who contains display_name) with tree view and without form view', function (assert) { + assert.expect(1); + + // avoid error in _fetchX2Manys + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 1, + archs: { + "turtle,false,form": '<form string="Turtles"><field name="turtle_foo"/></form>', + }, + }); + + form.$('.o_data_row:first').click(); + + assert.strictEqual($('.modal .o_form_field[name="turtle_foo"]').text(), 'blip', + 'should open the modal and display the form field'); + + form.destroy(); + }); + + QUnit.test('one2many field with virtual ids', function (assert) { + assert.expect(11); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<notebook>' + + '<page>' + + '<field name="p" mode="kanban">' + + '<kanban>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_details">' + + '<div class="o_test_id">' + + '<field name="id"/>' + + '</div>' + + '<div class="o_test_foo">' + + '<field name="foo"/>' + + '</div>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '</field>' + + '</page>' + + '</notebook>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,form': '<form string="Associated partners">' + + '<field name="foo"/>' + + '</form>', + }, + res_id: 4, + }); + + assert.strictEqual(form.$('.o_form_field .o_kanban_view').length, 1, + "should have one inner kanban view for the one2many field"); + assert.strictEqual(form.$('.o_form_field .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 0, + "should not have kanban records yet"); + + // // switch to edit mode and create a new kanban record + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field .o-kanban-button-new').click(); + + // save & close the modal + assert.strictEqual($('.modal-content input.o_form_field').val(), 'My little Foo Value', + "should already have the default value for field foo"); + $('.modal-content .btn-primary').first().click(); + + assert.strictEqual(form.$('.o_form_field .o_kanban_view').length, 1, + "should have one inner kanban view for the one2many field"); + assert.strictEqual(form.$('.o_form_field .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1, + "should now have one kanban record"); + assert.strictEqual(form.$('.o_form_field .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_id').text(), + '', "should not have a value for the id field"); + assert.strictEqual(form.$('.o_form_field .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_foo').text(), + 'My little Foo Value', "should have a value for the foo field"); + + // save the view to force a create of the new record in the one2many + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_form_field .o_kanban_view').length, 1, + "should have one inner kanban view for the one2many field"); + assert.strictEqual(form.$('.o_form_field .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1, + "should now have one kanban record"); + assert.notEqual(form.$('.o_form_field .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_id').text(), + '', "should now have a value for the id field"); + assert.strictEqual(form.$('.o_form_field .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_foo').text(), + 'My little Foo Value', "should still have a value for the foo field"); + + form.destroy(); + }); + + QUnit.test('focusing fields in one2many list', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_foo"/>' + + '<field name="turtle_int"/>' + + '</tree>' + + '</field>' + + '</group>' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + form.$buttons.find('.o_form_button_edit').click(); + + form.$('.o_data_row:first td:first').click(); + assert.strictEqual(form.$('.o_form_input[name="turtle_foo"]')[0], document.activeElement, + "turtle foo field should have focus"); + + form.$('.o_form_input[name="turtle_foo"]').trigger({type: 'keydown', which: $.ui.keyCode.TAB}); + assert.strictEqual(form.$('.o_form_input[name="turtle_int"]')[0], document.activeElement, + "turtle int field should have focus"); + form.destroy(); + }); + + QUnit.test('one2many list editable = top', function (assert) { + assert.expect(6); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + var commands = args.args[1].turtles; + assert.strictEqual(commands[0][0], 0, + "first command is a create"); + assert.strictEqual(commands[1][0], 4, + "second command is a link to"); + } + return this._super.apply(this, arguments); + }, + }); + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "should start with one data row"); + + form.$('.o_form_field_x2many_list_row_add a').click(); + + assert.strictEqual(form.$('.o_data_row').length, 2, + "should have 2 data rows"); + assert.ok(form.$('tr.o_data_row:first:contains(My little Foo Value)').length, 1, + "first row should be the new value"); + assert.ok(form.$('tr.o_data_row:first').hasClass('o_selected_row'), + "first row should be selected"); + + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); + + QUnit.test('one2many list editable = bottom', function (assert) { + assert.expect(6); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group>' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + var commands = args.args[1].turtles; + assert.strictEqual(commands[0][0], 4, + "first command is a link to"); + assert.strictEqual(commands[1][0], 0, + "second command is a create"); + } + return this._super.apply(this, arguments); + }, + }); + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "should start with one data row"); + + form.$('.o_form_field_x2many_list_row_add a').click(); + + assert.strictEqual(form.$('.o_data_row').length, 2, + "should have 2 data rows"); + assert.ok(form.$('tr.o_data_row:eq(1):contains(My little Foo Value)').length, 1, + "second row should be the new value"); + assert.ok(form.$('tr.o_data_row:eq(1)').hasClass('o_selected_row'), + "second row should be selected"); + + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); + + QUnit.module('FieldMany2Many'); + + QUnit.test('many2many kanban: edition', function (assert) { + assert.expect(28); + + this.data.partner.records[0].timmy = [12, 14]; + this.data.partner_type.records.push({id: 15, display_name: "red", color: 6}); + this.data.partner_type.records.push({id: 18, display_name: "yellow", color: 4}); + this.data.partner_type.records.push({id: 21, display_name: "blue", color: 1}); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="timmy">' + + '<kanban>' + + '<field name="display_name"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<a t-if="!read_only_mode" type="delete" class="fa fa-times pull-right delete_icon"/>' + + '<span><t t-esc="record.display_name.value"/></span>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>' + + '</field>' + + '</form>', + archs: { + 'partner_type,false,form': '<form string="Types"><field name="display_name"/></form>', + 'partner_type,false,list': '<tree string="Types"><field name="display_name"/></tree>', + 'partner_type,false,search': '<search string="Types">' + + '<field name="name" string="Name"/>' + + '</search>', + }, + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner_type/write') { + assert.strictEqual(args.args[1].display_name, "new name", "should write 'new_name'"); + } + if (route === '/web/dataset/call_kw/partner_type/create') { + assert.strictEqual(args.args[0].display_name, "A new type", "should create 'A new type'"); + } + if (route === '/web/dataset/call_kw/partner/write') { + var commands = args.args[1].timmy; + assert.strictEqual(commands.length, 1, "should have generated one command"); + assert.strictEqual(commands[0][0], 6, "generated command should be REPLACE WITH"); + // get the created type's id + var createdType = _.findWhere(this.data.partner_type.records, { + display_name: "A new type" + }); + var ids = _.sortBy([12, 15, 18, 21].concat(createdType.id), _.identity.bind(_)); + assert.ok(_.isEqual(_.sortBy(commands[0][2], _.identity.bind(_)), ids), + "new value should be " + ids); + } + return this._super.apply(this, arguments); + }, + }); + + // the SelectCreateDialog requests the session, so intercept its custom + // event to specify a fake session to prevent it from crashing + testUtils.intercept(form, 'get_session', function (event) { + event.data.callback({user_context: {}}); + }); + + assert.ok(!form.$('.o_kanban_view .delete_icon').length, + 'delete icon should not be visible in readonly'); + assert.ok(!form.$('.o_form_field_many2many .o-kanban-button-new').length, + '"Add" button should not be visible in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2, + 'should contain 2 records'); + assert.strictEqual(form.$('.o_kanban_record:first() span').text(), 'gold', + 'display_name of subrecord should be the one in DB'); + assert.ok(form.$('.o_kanban_view .delete_icon').length, + 'delete icon should be visible in edit'); + assert.ok(form.$('.o_form_field_many2many .o-kanban-button-new').length, + '"Add" button should be visible in edit'); + + // edit existing subrecord + form.$('.oe_kanban_global_click:first()').click(); + + $('.modal .o_form_view .o_form_input').val('new name').trigger('input'); + $('.modal .modal-footer .btn-primary').click(); // save + assert.strictEqual(form.$('.o_kanban_record:first() span').text(), 'new name', + 'value of subrecord should have been updated'); + + // add subrecords + // -> single select + form.$('.o_form_field_many2many .o-kanban-button-new').click(); + assert.ok($('.modal .o_list_view').length, "should have opened a list view in a modal"); + assert.strictEqual($('.modal .o_list_view tbody .o_list_record_selector').length, 3, + "list view should contain 3 records"); + $('.modal .o_list_view tbody tr:contains(red)').click(); // select red + assert.ok(!$('.modal .o_list_view').length, "should have closed the modal"); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 3, + 'kanban should now contain 3 records'); + assert.ok(form.$('.o_kanban_record:contains(red)').length, + 'record "red" should be in the kanban'); + + // -> multiple select + form.$('.o_form_field_many2many .o-kanban-button-new').click(); + assert.ok($('.modal .o_select_button').prop('disabled'), "select button should be disabled"); + assert.strictEqual($('.modal .o_list_view tbody .o_list_record_selector').length, 2, + "list view should contain 2 records"); + $('.modal .o_list_view thead .o_list_record_selector input').click(); // select all + $('.modal .o_select_button').click(); // validate selection + assert.ok(!$('.modal .o_select_button').prop('disabled'), "select button should be enabled"); + assert.ok(!$('.modal .o_list_view').length, "should have closed the modal"); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 5, + 'kanban should now contain 5 records'); + // -> created record + form.$('.o_form_field_many2many .o-kanban-button-new').click(); + $('.modal .modal-footer .btn-primary:nth(1)').click(); // click on 'Create' + assert.ok($('.modal .o_form_view.o_form_editable').length, + "should have opened a form view in edit mode, in a modal"); + $('.modal .o_form_view input').val('A new type').trigger('input'); + $('.modal:nth(1) .modal-footer .btn-primary:first()').click(); // click on 'Save & Close' + assert.ok(!$('.modal').length, "should have closed both modals"); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 6, + 'kanban should now contain 6 records'); + assert.ok(form.$('.o_kanban_record:contains(A new type)').length, + 'the newly created type should be in the kanban'); + + // delete subrecords + form.$('.o_kanban_record:contains(silver) .delete_icon').click(); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 5, + 'should contain 5 records'); + assert.ok(!form.$('.o_kanban_record:contains(silver)').length, + 'the removed record should not be in kanban anymore'); + + // save the record + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); + + QUnit.test('many2many kanban: create action disabled', function (assert) { + assert.expect(4); + + this.data.partner.records[0].timmy = [12, 14]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy">' + + '<kanban create="0">' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<a t-if="!read_only_mode" type="delete" class="fa fa-times pull-right delete_icon"/>' + + '<span><t t-esc="record.display_name.value"/></span>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '</field>' + + '</form>', + archs: { + 'partner_type,false,list': '<tree><field name="name"/></tree>', + 'partner_type,false,search': '<search>' + + '<field name="display_name" string="Name"/>' + + '</search>', + }, + res_id: 1, + session: {user_context: {}}, + }); + + assert.ok(!form.$('.o-kanban-button-new').length, + '"Add" button should not be available in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(form.$('.o-kanban-button-new').length, + '"Add" button should be available in edit'); + assert.ok(form.$('.o_kanban_view .delete_icon').length, + 'delete icon should be visible in edit'); + + form.$('.o-kanban-button-new').click(); // click on 'Add' + assert.strictEqual($('.modal .modal-footer .btn-primary').length, 1, // only button 'Select' + '"Create" button should not be available in the modal'); + + form.destroy(); + }); + + QUnit.test('many2many list (non editable): edition', function (assert) { + assert.expect(9); + + this.data.partner.records[0].timmy = [12, 14]; + this.data.partner_type.fields.float_field = {string: 'Float', type: 'float'}; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="timmy">' + + '<tree>' + + '<field name="display_name"/><field name="float_field"/>' + + '</tree>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.ok(!form.$('.o_list_record_delete').length, + 'delete icon should not be visible in readonly'); + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" should not be visible in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2, + 'should contain 2 records'); + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'gold', + 'display_name of first subrecord should be the one in DB'); + assert.ok(form.$('.o_list_record_delete').length, + 'delete icon should be visible in edit'); + assert.ok(form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" should be visible in edit'); + + // edit existing subrecord + form.$('.o_list_view tbody tr:first()').click(); + + $('.modal .o_form_view .o_form_input').val('new name').trigger('input'); + $('.modal .modal-footer .btn-primary').click(); // save + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name', + 'value of subrecord should have been updated'); + + // create new subrecords + // TODO when 'Add an item' will be implemented + + // delete subrecords + form.$('.o_list_record_delete:first()').click(); + assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 1, + 'should contain 1 subrecord'); + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'silver', + 'the remaining subrecord should be "aaa"'); + form.destroy(); + }); + + QUnit.test('many2many list (editable): edition', function (assert) { + assert.expect(11); + + this.data.partner.records[0].timmy = [12, 14]; + this.data.partner_type.fields.float_field = {string: 'Float', type: 'float'}; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="timmy">' + + '<tree editable="top">' + + '<field name="display_name"/><field name="float_field"/>' + + '</tree>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.ok(!form.$('.o_list_record_delete').length, + 'delete icon should not be visible in readonly'); + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" should not be visible in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2, + 'should contain 2 records'); + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'gold', + 'display_name of first subrecord should be the one in DB'); + assert.ok(form.$('.o_list_record_delete').length, + 'delete icon should be visible in edit'); + assert.ok(form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" should not visible in edit'); + + // edit existing subrecord + form.$('.o_list_view tbody td:first()').click(); + assert.ok(!$('.modal').length, + 'in edit, clicking on a subrecord should not open a dialog'); + assert.ok(form.$('.o_list_view tbody tr:first()').hasClass('o_selected_row'), + 'first row should be in edition'); + form.$('.o_list_view .o_form_input:first()').val('new name').trigger('input'); + // FIXME: this doesn"t work for now as the x2many fields are reset on field changed + // form.$('.o_list_view tbody td:nth(1)').click(); // click on second record to validate the first one + // assert.ok(!form.$('.o_list_view tbody tr:first()').hasClass('o_selected_row'), + // 'first row should not be in edition anymore'); + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name', + 'value of subrecord should have been updated'); + + // add new subrecords + // TODO when 'Add an item' will be implemented + + // delete subrecords + form.$('.o_list_record_delete:first()').click(); + assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 1, + 'should contain 1 subrecord'); + assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'silver', + 'the remaining subrecord should be "aaa"'); + form.destroy(); + }); + + QUnit.test('many2many list: create action disabled', function (assert) { + assert.expect(2); + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy">' + + '<tree create="0">' + + '<field name="name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" link should not be available in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.ok(!form.$('.o_form_field_x2many_list_row_add').length, + '"Add an item" link should not be available in edit either'); + + form.destroy(); + }); + + QUnit.module('FieldStatus'); + + QUnit.test('static statusbar widget on many2one field', function (assert) { + assert.expect(5); + + this.data.partner.fields.trululu.domain = "[('bar', '=', True)]"; + this.data.partner.records[1].bar = false; + + var count = 0; + var nb_fields_fetched; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<header><field name="trululu" widget="statusbar"/></header>' + + // the following field seem useless, but its presence was the + // cause of a crash when evaluating the field domain. + '<field name="timmy" invisible="1"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'search_read') { + count++; + nb_fields_fetched = args.args[1].length; + } + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + + assert.strictEqual(count, 1, 'once search_read should have been done to fetch the relational values'); + assert.strictEqual(nb_fields_fetched, 1, 'search_read should only fetch field id'); + assert.strictEqual(form.$('.o_statusbar_status button:not(.dropdown-toggle)').length, 2, "should have 2 status"); + assert.strictEqual(form.$('.o_statusbar_status button:disabled').length, 2, + "all status should be disabled"); + assert.ok(form.$('.o_statusbar_status button[data-value="4"]').hasClass('btn-primary'), + "selected status should be btn-primary"); + + form.destroy(); + }); + + QUnit.test('static statusbar widget on many2one field with domain', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<header><field name="trululu" domain="[(\'user_id\',\'=\',uid)]" widget="statusbar"/></header>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'search_read') { + assert.deepEqual(args.args[0], ['|', ['id', '=', 4], ['user_id', '=', 17]], + "search_read should sent the correct domain"); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + session: {user_context: {uid: 17}}, + }); + + form.destroy(); + }); + + QUnit.test('clickable statusbar widget on many2one field', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<header><field name="trululu" widget="statusbar" clickable="True"/></header>' + + '</form>', + res_id: 1, + }); + + assert.ok(form.$('.o_statusbar_status button[data-value="4"]').hasClass('btn-primary disabled'), + "selected status should be btn-primary and disabled"); + var $clickable = form.$('.o_statusbar_status button.btn-default:not(.dropdown-toggle):not(:disabled)'); + assert.strictEqual($clickable.length, 2, + "other status should be btn-default and not disabled"); + $clickable.last().click(); // (last is visually the first here (css)) + assert.ok(form.$('.o_statusbar_status button[data-value="1"]').hasClass("btn-primary disabled"), + "value should have been updated"); + + form.destroy(); + }); + + QUnit.test('statusbar with no status', function (assert) { + assert.expect(2); + + this.data.product.records = []; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<header><field name="product_id" widget="statusbar"/></header>' + + '</form>', + res_id: 1, + }); + + assert.ok(form.$('.o_statusbar_status').hasClass('o_form_field_empty'), + 'statusbar widget should have class o_form_field_empty'); + assert.strictEqual(form.$('.o_statusbar_status').children().length, 0, + 'statusbar widget should be empty'); + form.destroy(); + }); + + QUnit.test('statusbar with domain but no value (create mode)', function (assert) { + assert.expect(1); + + this.data.partner.fields.trululu.domain = "[('bar', '=', True)]"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<header><field name="trululu" widget="statusbar"/></header>' + + '</form>', + }); + + assert.strictEqual(form.$('.o_statusbar_status button:disabled').length, 2, "should have 2 status"); + + form.destroy(); + }); + + QUnit.test('clickable statusbar should change m2o fetching domain in edit mode', function (assert) { + assert.expect(2); + + this.data.partner.fields.trululu.domain = "[('bar', '=', True)]"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<header><field name="trululu" widget="statusbar" clickable="True"/></header>' + + '</form>', + res_id: 1, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + var $buttons = form.$('.o_statusbar_status button:not(.dropdown-toggle)'); + assert.strictEqual($buttons.length, 3, "there should be 3 status"); + $buttons.last().click(); // (last is visually the first here (css)) + assert.strictEqual(form.$('.o_statusbar_status button:not(.dropdown-toggle)').length, 2, + "there should be 2 status left"); + + form.destroy(); + }); + + QUnit.test('statusbar fold_field option and statusbar_visible attribute', function (assert) { + assert.expect(2); + + this.data.partner.records[0].bar = false; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<header><field name="trululu" widget="statusbar" options="{\'fold_field\': \'bar\'}"/>' + + '<field name="color" widget="statusbar" statusbar_visible="red"/></header>' + + '</form>', + res_id: 1, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_statusbar_status:first .dropdown-menu button.disabled').length, 1, "should have 1 folded status"); + assert.strictEqual(form.$('.o_statusbar_status:last button.disabled').length, 1, "should have 1 status (other discarded)"); + + form.destroy(); + }); + + QUnit.test('statusbar with dynamic domain', function (assert) { + assert.expect(5); + + this.data.partner.fields.trululu.domain = "[('int_field', '>', qux)]"; + this.data.partner.records[2].int_field = 0; + + var rpcCount = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<header><field name="trululu" widget="statusbar"/></header>' + + '<field name="qux"/>' + + '<field name="foo"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'search_read') { + rpcCount++; + } + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_statusbar_status button.disabled').length, 3, "should have 3 status"); + assert.strictEqual(rpcCount, 1, "should have done 1 search_read rpc"); + form.$('input:first').val(9.5).trigger("input").trigger("change"); + assert.strictEqual(form.$('.o_statusbar_status button.disabled').length, 2, "should have 2 status"); + assert.strictEqual(rpcCount, 2, "should have done 1 more search_read rpc"); + form.$('input:last').val("hey").trigger("input").trigger("change"); + assert.strictEqual(rpcCount, 2, "should not have done 1 more search_read rpc"); + + form.destroy(); + }); + + QUnit.module('FieldSelection'); + + QUnit.test('widget selection in a list view', function (assert) { + assert.expect(3); + + this.data.partner.records.forEach(function (r) { + r.color = 'red'; + }); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree string="Colors" editable="top">' + + '<field name="color"/>' + + '</tree>', + }); + + assert.strictEqual(list.$('td:contains(Red)').length, 3, + "should have 3 rows with correct value"); + list.$('td:contains(Red):first').click(); + + var $td = list.$('tbody tr.o_selected_row td:not(.o_list_record_selector)'); + + assert.strictEqual($td.find('select').length, 1, "td should have a child 'select'"); + assert.strictEqual($td.contents().length, 1, "select tag should be only child of td"); + list.destroy(); + }); + + QUnit.test('widget selection, edition and on many2one field', function (assert) { + assert.expect(15); + + this.data.partner.onchanges = {product_id: function () {}}; + this.data.partner.records[0].product_id = 37; + this.data.partner.records[0].trululu = false; + + var count = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="product_id" widget="selection"/>' + + '<field name="trululu" widget="selection"/>' + + '<field name="color" widget="selection"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + count++; + assert.step(args.method); + return this._super(route, args); + }, + }); + + assert.ok(!form.$('select').length, "should not have a select tag in dom"); + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('select').length, 3, + "should have 3 select tag in dom"); + assert.strictEqual(form.$('select[name="product_id"] option:contains(xphone)').length, 1, + "should have fetched xphone option"); + assert.strictEqual(form.$('select[name="product_id"] option:contains(xpad)').length, 1, + "should have fetched xpad option"); + assert.strictEqual(form.$('select[name="product_id"]').val(), "37", + "should have correct product_id value"); + assert.strictEqual(form.$('select[name="trululu"]').val(), "false", + "should not have any value in trululu field"); + form.$('select[name="product_id"]').val(41).trigger('change'); + + assert.strictEqual(form.$('select[name="product_id"]').val(), "41", + "should have a value of xphone"); + + assert.strictEqual(form.$('select[name="color"]').val(), "\"red\"", + "should have correct value in color field"); + + assert.verifySteps(['read', 'name_search', 'name_search', 'onchange']); + count = 0; + form.reload(); + assert.strictEqual(count, 1, "should not reload product_id relation"); + form.destroy(); + }); + + QUnit.module('FieldMany2ManyTags'); + + QUnit.test('fieldmany2many tags: rendering and edition', function (assert) { + assert.expect(17); + + this.data.partner.records[0].timmy = [12, 14]; + this.data.partner_type.records.push({id: 13, display_name: "red", color: 8}); + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy" widget="many2many_tags" options="{\'no_create_edit\': True}"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/write') { + var commands = args.args[1].timmy; + assert.strictEqual(commands.length, 1, "should have generated one command"); + assert.strictEqual(commands[0][0], 6, "generated command should be REPLACE WITH"); + assert.ok(_.isEqual(_.sortBy(commands[0][2], _.identity.bind(_)), [12, 13]), + "new value should be [12, 13]"); + } + return this._super.apply(this, arguments); + }, + }); + assert.strictEqual(form.$('.o_form_field_many2manytags > span').length, 2, + "should contain 2 tags"); + assert.ok(form.$('span:contains(gold)').length, + 'should have fetched and rendered gold partner tag'); + assert.ok(form.$('span:contains(silver)').length, + 'should have fetched and rendered silver partner tag'); + assert.strictEqual(form.$('span:first()').data('color'), 2, + 'should have correctly fetched the color'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_form_field_many2manytags > span').length, 2, + "should still contain 2 tags in edit mode"); + assert.ok(form.$('.o_tag_color_2 .o_badge_text:contains(gold)').length, + 'first tag should still contain "gold" and be color 2 in edit mode'); + assert.strictEqual(form.$('.o_form_field_many2manytags .o_delete').length, 2, + "tags should contain a delete button"); + + // add an other existing tag + var $input = form.$('.o_form_field_many2manytags input'); + $input.click(); // opens the dropdown + assert.strictEqual($input.autocomplete('widget').find('li').length, 1, + "autocomplete dropdown should have 1 entry"); + assert.strictEqual($input.autocomplete('widget').find('li a:contains("red")').length, 1, + "autocomplete dropdown should contain 'red'"); + $input.autocomplete('widget').find('li').click(); // add 'red' + assert.strictEqual(form.$('.o_form_field_many2manytags > span').length, 3, + "should contain 3 tags"); + assert.ok(form.$('.o_form_field_many2manytags > span:contains("red")').length, + "should contain newly added tag 'red'"); + assert.ok(form.$('.o_form_field_many2manytags > span[data-color=8]:contains("red")').length, + "should have fetched the color of added tag"); + + // remove tag with id 14 + form.$('.o_form_field_many2manytags span[data-id=14] .o_delete').click(); + assert.strictEqual(form.$('.o_form_field_many2manytags > span').length, 2, + "should contain 2 tags"); + assert.ok(!form.$('.o_form_field_many2manytags > span:contains("silver")').length, + "should not contain tag 'silver' anymore"); + + // save the record (should do the write RPC with the correct commands) + form.$buttons.find('.o_form_button_save').click(); + + // TODO: it would be nice to test the behaviors of the autocomplete dropdown + // (like refining the research, creating new tags...), but ui-autocomplete + // makes it difficult to test + form.destroy(); + }); + + QUnit.test('fieldmany2many tags view a domain', function (assert) { + assert.expect(7); + + this.data.partner.fields.timmy.domain = [['id', '<', 50]]; + this.data.partner.records[0].timmy = [12]; + this.data.partner_type.records.push({id: 99, display_name: "red", color: 8}); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy" widget="many2many_tags" options="{\'no_create_edit\': True}"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.deepEqual(args.kwargs.args, [['id', '<', 50], ['id', 'not in', [12]]], + "domain sent to name_search should be correct"); + return $.when([[14, 'silver']]); + } + return this._super.apply(this, arguments); + } + }); + assert.strictEqual(form.$('.o_form_field_many2manytags > span').length, 1, + "should contain 1 tag"); + assert.ok(form.$('span:contains(gold)').length, + 'should have fetched and rendered gold partner tag'); + + form.$buttons.find('.o_form_button_edit').click(); + + // add an other existing tag + var $input = form.$('.o_form_field_many2manytags input'); + $input.click(); // opens the dropdown + assert.strictEqual($input.autocomplete('widget').find('li').length, 1, + "autocomplete dropdown should have 1 entry"); + assert.strictEqual($input.autocomplete('widget').find('li a:contains("silver")').length, 1, + "autocomplete dropdown should contain 'silver'"); + $input.autocomplete('widget').find('li').click(); // add 'silver' + assert.strictEqual(form.$('.o_form_field_many2manytags > span').length, 2, + "should contain 2 tags"); + assert.ok(form.$('.o_form_field_many2manytags > span:contains("silver")').length, + "should contain newly added tag 'silver'"); + + form.destroy(); + }); + + QUnit.test('fieldmany2many tags in a new record', function (assert) { + assert.expect(7); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy" widget="many2many_tags"/>' + + '</form>', + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/create') { + var commands = args.args[0].timmy; + assert.strictEqual(commands.length, 1, "should have generated one command"); + assert.strictEqual(commands[0][0], 6, "generated command should be REPLACE WITH"); + assert.ok(_.isEqual(commands[0][2], [12]), "new value should be [12]"); + } + return this._super.apply(this, arguments); + } + }); + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), "form should be in edit mode"); + + var $input = form.$('.o_form_field_many2manytags input'); + $input.click(); // opens the dropdown + assert.strictEqual($input.autocomplete('widget').find('li').length, 3, + "autocomplete dropdown should have 3 entries (2 values + 'Search and Edit...')"); + $input.autocomplete('widget').find('li:first()').click(); // adds a tag + assert.strictEqual(form.$('.o_form_field_many2manytags > span').length, 1, + "should contain 1 tag"); + assert.ok(form.$('.o_form_field_many2manytags > span:contains("gold")').length, + "should contain newly added tag 'gold'"); + + // save the record (should do the write RPC with the correct commands) + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); + + QUnit.test('fieldmany2many tags: update color', function (assert) { + assert.expect(2); + + this.data.partner.records[0].timmy = [12, 14]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy" widget="many2many_tags"/>' + + '</form>', + res_id: 1, + }); + + // Update the color in readonly + form.$('span:first()').click(); + $('.o_colorpicker span[data-color="1"]').trigger('mousedown'); // choose color 1 + assert.strictEqual(form.$('span:first()').data('color'), 1, + 'should have correctly updated the color (in readonly)'); + + // Update the color in edit + form.$buttons.find('.o_form_button_edit').click(); + form.$('span:first()').click(); + $('.o_colorpicker span[data-color="6"]').trigger('mousedown'); // choose color 6 + assert.strictEqual(form.$('span:first()').data('color'), 6, + 'should have correctly updated the color (in edit)'); + form.destroy(); + }); + + QUnit.test('fieldmany2many tags in editable list', function (assert) { + assert.expect(5); + + this.data.partner.records[0].timmy = [12]; + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch:'<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="timmy" widget="many2many_tags"/>' + + '</tree>', + }); + + assert.strictEqual(list.$('.o_data_row:first .o_form_field_many2manytags .badge').length, 1, + "m2m field should contain one tag"); + + // edit first row + list.$('.o_data_row:first td:nth(2)').click(); + + var $m2o = list.$('.o_data_row:first .o_form_field_many2manytags .o_form_field_many2one'); + assert.strictEqual($m2o.length, 1, "a many2one widget should have been instantiated"); + + // add a tag + var $input = $m2o.find('input'); + $input.click(); + $input.autocomplete('widget').find('li:first()').click(); // adds a tag + + assert.strictEqual(list.$('.o_data_row:first .o_form_field_many2manytags .badge').length, 2, + "m2m field should contain 2 tags"); + assert.ok(list.$('.o_data_row:first .o_many2many_tags_cell').hasClass('o_field_dirty'), + "edited cell should have class o_field_dirty"); + + // leave edition + list.$('.o_data_row:nth(1) td:nth(2)').click(); + + assert.strictEqual(list.$('.o_data_row:first .o_form_field_many2manytags .badge').length, 2, + "m2m field should contain 2 tags"); + + list.destroy(); + }); + + QUnit.module('FieldRadio'); + + QUnit.test('fieldradio widget on a many2one in a new record', function (assert) { + assert.expect(6); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="product_id" widget="radio"/>' + + '</form>', + }); + + assert.ok(form.$('div.o_radio_item').length, "should have rendered outer div"); + assert.strictEqual(form.$('input.o_form_radio').length, 2, "should have 2 possible choices"); + assert.ok(form.$('label.o_form_label:contains(xphone)').length, "one of them should be xphone"); + assert.strictEqual(form.$('input:checked').length, 0, "none of the input should be checked"); + + form.$("input.o_form_radio:first").click(); + + assert.strictEqual(form.$('input:checked').length, 1, "one of the input should be checked"); + + form.$buttons.find('.o_form_button_save').click(); + + var newRecord = _.last(this.data.partner.records); + assert.strictEqual(newRecord.product_id, 37, "should have saved record with correct value"); + form.destroy(); + }); + + QUnit.test('fieldradio change value by onchange', function (assert) { + assert.expect(4); + + this.data.partner.onchanges = {bar: function (obj) { + obj.product_id = obj.bar ? 41 : 37; + obj.color = obj.bar ? 'red' : 'black'; + }}; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="bar"/>' + + '<field name="product_id" widget="radio"/>' + + '<field name="color" widget="radio"/>' + + '</form>', + }); + + form.$("input[type='checkbox']").click(); + assert.strictEqual(form.$('input.o_form_radio[data-value="37"]:checked').length, 1, "one of the input should be checked"); + assert.strictEqual(form.$('input.o_form_radio[data-value="black"]:checked').length, 1, "the other of the input should be checked"); + form.$("input[type='checkbox']").click(); + assert.strictEqual(form.$('input.o_form_radio[data-value="41"]:checked').length, 1, "the other of the input should be checked"); + assert.strictEqual(form.$('input.o_form_radio[data-value="red"]:checked').length, 1, "one of the input should be checked"); + + form.destroy(); + }); + + + QUnit.test('fieldradio widget on a selection in a new record', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="color" widget="radio"/>' + + '</form>', + }); + + + assert.ok(form.$('div.o_radio_item').length, "should have rendered outer div"); + assert.strictEqual(form.$('input.o_form_radio').length, 2, "should have 2 possible choices"); + assert.ok(form.$('label.o_form_label:contains(Red)').length, "one of them should be Red"); + + // click on 2nd option + form.$("input.o_form_radio").eq(1).click(); + + form.$buttons.find('.o_form_button_save').click(); + + var newRecord = _.last(this.data.partner.records); + assert.strictEqual(newRecord.color, 'black', "should have saved record with correct value"); + form.destroy(); + }); + + QUnit.module('FieldMany2ManyCheckBoxes'); + + QUnit.test('widget many2many_checkboxes', function (assert) { + assert.expect(6); + + this.data.partner.records[0].timmy = [12]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<group><field name="timmy" widget="many2many_checkboxes"/></group>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('div.o_form_field div.o_checkbox').length, 2, + "should have fetched and displayed the 2 values of the many2many"); + + assert.ok(form.$('div.o_form_field div.o_checkbox input').eq(0).prop('checked'), + "first checkbox should be checked"); + assert.notOk(form.$('div.o_form_field div.o_checkbox input').eq(1).prop('checked'), + "second checkbox should not be checked"); + + assert.ok(form.$('div.o_form_field div.o_checkbox input').prop('disabled'), + "the checkboxes should be disabled"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.notOk(form.$('div.o_form_field div.o_checkbox input').prop('disabled'), + "the checkboxes should not be disabled"); + + form.$('div.o_form_field div.o_checkbox input').eq(1).click(); + form.$buttons.find('.o_form_button_save').click(); + assert.deepEqual(this.data.partner.records[0].timmy, [12, 14], + "should have added the second element to the many2many"); + + form.destroy(); + }); +}); +}); +}); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js new file mode 100644 index 00000000000..de5e02934f0 --- /dev/null +++ b/addons/web/static/tests/helpers/mock_server.js @@ -0,0 +1,941 @@ +odoo.define('web.MockServer', function (require) { +"use strict"; + +var Class = require('web.Class'); +var data_manager = require('web.data_manager'); +var Domain = require('web.Domain'); +var pyeval = require('web.pyeval'); +var utils = require('web.utils'); + +var MockServer = Class.extend({ + /** + * @constructor + * @param {Object} data + * @param {Object} options + * @param {integer} [options.logLevel=0] + * @param {string} [options.currentDate] formatted string, default to + * current day + */ + init: function (data, options) { + this.data = data; + for (var modelName in this.data) { + var model = this.data[modelName]; + if (!('id' in model.fields)) { + model.fields.id = {string: "ID", type: "integer"}; + } + if (!('display_name' in model.fields)) { + model.fields.display_name = {string: "Display Name", type: "char"}; + } + if (!('name' in model.fields)) { + model.fields.name = {string: "Name", type: "char", default: "name"}; + } + for (var fieldName in model.onchanges) { + model.fields[fieldName].onChange = "1"; + } + model.records = model.records || []; + + for (var i = 0; i < model.records.length; i++) { + var record = model.records[i]; + this._applyDefaults(model, record); + } + } + + // 0 is for no log + // 1 is for short + // 2 is for detailed + this.logLevel = (options && options.logLevel) || 0; + + this.currentDate = options.currentDate || moment().format("YYYY-MM-DD"); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * helper: read a string describing an arch, and returns a simulated + * 'field_view_get' call to the server. Calls processViews() of data_manager + * to mimick the real behavior of a call to loadViews(). + * + * @param {string|Object} arch a string OR a parsed xml document + * @param {string} model a model name (that should be in this.data) + * @returns {Object} an object with 2 keys: arch and fields + */ + fieldsViewGet: function (arch, model) { + var fields = $.extend(true, {}, this.data[model].fields); + var fvg = this._fieldsViewGet(arch, model, fields); + var fields_views = {}; + fields_views[fvg.type] = fvg; + data_manager.processViews(fields_views, fields); + return fields_views[fvg.type]; + }, + /** + * Simulate a complete RPC call. This is the main method for this class. + * + * This method also log incoming and outgoing data, and stringify/parse data + * to simulate a barrier between the server and the client. It also simulate + * server errors. + * + * @param {string} route + * @param {Object} args + * @returns {Deferred<any>} + * Resolved with the result of the RPC, stringified then parsed. + * If the RPC should fail, the deferred will be rejected with the + * error object, stringified then parsed. + */ + performRpc: function (route, args) { + var logLevel = this.logLevel; + args = JSON.parse(JSON.stringify(args)); + if (logLevel === 2) { + console.log('%c[rpc] request ' + route, 'color: blue; font-weight: bold;', args); + } + var def; + try { + def = this._performRpc(route, args); + } catch (e) { + var error = {code: 200, data: {}, message: e.message}; + if (logLevel === 1) { + console.warn('Mock: ' + route, error.message); + } else if (logLevel === 2) { + console.warn('%c[rpc] error response:', 'color: blue; font-weight: bold;', error.message); + } + return $.Deferred().reject(error, $.Event()); + } + return def.then(function (result) { + var resultString = JSON.stringify(result || false); + if (logLevel === 1) { + console.log('Mock: ' + route, JSON.parse(resultString)); + } else if (logLevel === 2) { + console.log('%c[rpc] response:', 'color: blue; font-weight: bold;', JSON.parse(resultString)); + } + return JSON.parse(resultString); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply the default values when creating an object in the local database. + * + * @private + * @param {Object} model a model object from the local database + * @param {Object} record + */ + _applyDefaults: function (model, record) { + record.display_name = record.display_name || record.name; + for (var fieldName in model.fields) { + if (fieldName === 'id') { + continue; + } + if (!(fieldName in record)) { + if ('default' in model.fields[fieldName]) { + record[fieldName] = model.fields[fieldName].default; + } else if (_.contains(['one2many', 'many2many'], model.fields[fieldName].type)) { + record[fieldName] = []; + } else { + record[fieldName] = false; + } + } + } + }, + /** + * helper to evaluate a domain for given field values. + * Currently, this is only a wrapper of the Domain.compute function in + * "web.Domain". + * + * @param {Array} domain + * @param {Object} fieldValues + * @returns {boolean} + */ + _evaluateDomain: function (domain, fieldValues) { + return new Domain(domain).compute(fieldValues); + }, + /** + * helper: read a string describing an arch, and returns a simulated + * 'field_view_get' call to the server. + * + * @private + * @param {string|Object} arch a string OR a parsed xml document + * @param {string} model a model name (that should be in this.data) + * @param {Object} fields + * @returns {Object} an object with 2 keys: arch and fields (the fields + * appearing in the views) + */ + _fieldsViewGet: function (arch, model, fields) { + var self = this; + var onchanges = this.data[model].onchanges || {}; + var fieldNodes = {}; + + if (typeof arch === 'string') { + var doc = $.parseXML(arch).documentElement; + arch = utils.xml_to_json(doc, true); + } + + this._traverse(arch, function (node) { + if (typeof node === "string") { + return false; + } + var modifiers = {}; + if (node.attrs.attrs) { + var attrs = pyeval.py_eval(node.attrs.attrs); + _.extend(modifiers, attrs); + delete node.attrs.attrs; + } + if (node.attrs.states) { + if (!modifiers.invisible) { + modifiers.invisible = []; + } + modifiers.invisible.push(["state", "not in", node.attrs.states.split(",")]); + } + _.each(["invisible", "readonly", "required"], function (a) { + if (node.attrs[a]) { + var v = pyeval.py_eval(node.attrs[a]); + if (v || modifiers[a] === undefined) { + modifiers[a] = v; + } + } + }); + node.attrs.modifiers = JSON.stringify(modifiers); + if (node.tag === 'field') { + fieldNodes[node.attrs.name] = node; + return false; + } + return true; + }); + + var relModel, relFields; + _.each(fieldNodes, function (node, name) { + var field = fields[name]; + if (field.type === "one2many" || field.type === "many2many") { + field.views = {}; + _.each(node.children, function (children) { + relModel = field.relation; + relFields = $.extend(true, {}, self.data[relModel].fields); + field.views[children.tag] = self._fieldsViewGet(children, relModel, relFields); + }); + } + + // add onchanges + if (name in onchanges) { + field.onChange="1"; + } + }); + return { + arch: arch, + fields: _.pick(fields, _.keys(fieldNodes)), + model: model, + type: arch.tag === 'tree' ? 'list' : arch.tag, + }; + }, + /** + * Get all records from a model matching a domain. The only difficulty is + * that if we have an 'active' field, we implicitely add active = true in + * the domain. + * + * @private + * @param {string} model a model name + * @param {any[]} domain + * @returns {Object[]} a list of records + */ + _getRecords: function (model, domain) { + if (!_.isArray(domain)) { + throw new Error("MockServer._getRecords: given domain has to be an array."); + } + + var self = this; + var records = this.data[model].records; + + if ('active' in this.data[model].fields) { + // add ['active', '=', true] to the domain if 'active' is not yet present in domain + var activeInDomain = false; + _.each(domain, function (subdomain) { + activeInDomain = activeInDomain || subdomain[0] === 'active'; + }); + if (!activeInDomain) { + domain.unshift(['active', '=', true]); + } + } + + if (domain.length) { + records = _.filter(records, function (record) { + var fieldValues = _.mapObject(record, function (value) { + return value instanceof Array ? value[0] : value; + }); + return self._evaluateDomain(domain, fieldValues); + }); + } + + return records; + }, + /** + * Helper function, to find an available ID. The current algorithm is to add + * all other IDS. + * + * @private + * @param {string} modelName + * @returns {integer} a valid ID (> 0) + */ + _getUnusedID: function (modelName) { + var model = this.data[modelName]; + return _.reduce(model.records, function (acc, record){ + return acc + record.id; + }, 1); + }, + /** + * Simulate a 'copy' operation, so we simply try to duplicate a record in + * memory + * + * @private + * @param {string} modelName + * @param {integer} id the ID of a valid record + * @returns {integer} the ID of the duplicated record + */ + _mockCopy: function (modelName, id) { + var model = this.data[modelName]; + var newID = this._getUnusedID(modelName); + var originalRecord = _.findWhere(model.records, {id: id}); + var duplicateRecord = _.extend({}, originalRecord, {id: newID}); + duplicateRecord.display_name = originalRecord.display_name + ' (copy)'; + model.records.push(duplicateRecord); + return newID; + }, + /** + * Simulate a 'create' operation. This is basically a 'write' with the + * added work of getting a valid ID and applying default values. + * + * @private + * @param {string} modelName + * @param {Object} values + * @returns {integer} + */ + _mockCreate: function (modelName, values) { + if ('id' in values) { + throw "Cannot create a record with a predefinite id"; + } + var model = this.data[modelName]; + var id = this._getUnusedID(modelName); + var record = {id: id}; + model.records.push(record); + this._applyDefaults(model, values); + this._mockWrite(modelName, [[id], values]); + return id; + }, + /** + * Simulate a 'default_get' operation + * + * @private + * @param {string} modelName + * @param {array[]} args a list with a list of fields in the first position + * @param {Object} [kwargs] + * @param {Object} [kwargs.context] the context to eventually read default + * values + * @returns {Object} + */ + _mockDefaultGet: function (modelName, args, kwargs) { + var result = {}; + var fields = args[0]; + var model = this.data[modelName]; + _.each(fields, function (name) { + var field = model.fields[name]; + if ('default' in field) { + result[name] = field.default; + } + }); + if (kwargs && kwargs.context) + _.each(kwargs.context, function (value, key) { + if ('default_' === key.slice(0, 8)) { + result[key.slice(8)] = value; + } + }); + return result; + }, + /** + * Simulate a 'field_get' operation + * + * @private + * @param {string} modelName + * @param {any} args + * @returns {Object} + */ + _mockFieldsGet: function (modelName, args) { + var modelFields = this.data[modelName].fields; + // Get only the asked fields (args[0] could be the field names) + if (args[0] && args[0].length) { + modelFields = _.pick.apply(_, [modelFields].concat(args[0])); + } + // Get only the asked attributes (args[1] could be the attribute names) + if (args[1] && args[1].length) { + modelFields = _.mapObject(modelFields, function (field) { + return _.pick.apply(_, [field].concat(args[1])); + }); + } + return modelFields; + }, + /** + * Simulate a 'name_get' operation + * + * @private + * @param {string} model + * @param {Array} args + * @returns {Array[]} a list of [id, display_name] + */ + _mockNameGet: function (model, args) { + var ids = args[0]; + if (!_.isArray(ids)) { + ids = [ids]; + } + var records = this.data[model].records; + var names = _.map(ids, function (id) { + return [id, _.findWhere(records, {id: id}).display_name]; + }); + return names; + }, + /** + * Simulate a 'name_create' operation + * + * @private + * @param {string} model + * @param {Array} args + * @returns {Array} a couple [id, name] + */ + _mockNameCreate: function (model, args) { + var name = args[0]; + var values = { + name: name, + display_name: name, + }; + var id = this._mockCreate(model, values); + return [id, name]; + }, + /** + * Simulate a 'name_search' operation. + * + * not yet fully implemented (missing: limit, and evaluate operators) + * domain works but only to filter on ids + * + * @private + * @param {string} model + * @param {Array} args + * @param {string} args[0] + * @param {Array} args[1], search domain + * @param {Object} _kwargs + * @returns {Array[]} a list of [id, display_name] + */ + _mockNameSearch: function (model, args, _kwargs) { + var str = args && typeof args[0] === 'string' ? args[0] : _kwargs.name; + var domain = (args && args[1]) || _kwargs.args || []; + var dom = domain[0]; + var records = this.data[model].records; + if (dom) { + records = _.filter(records, function (record) { + var value = record[dom[0]]; + if (value instanceof Array) { + value = value[0]; + } + if (dom[1] === 'not in') { + return !_.contains(dom[2], value); + } else if (dom[1] === 'in') { + return _.contains(dom[2], value); + } else if (dom[1] === '==') { + return dom[2] == value; + } else if (dom[1] === '!=') { + return dom[2] != value; + } + }); + } + if (str.length) { + records = _.filter(records, function (record) { + return record.display_name.indexOf(str) !== -1; + }); + } + return _.map(records, function (record) { + return [record.id, record.display_name]; + }); + }, + /** + * Simulate an 'onchange' rpc + * + * @private + * @param {string} model + * @param {string|string[]} args a list of field names, or just a field name + * @returns {Object} + */ + _mockOnchange: function (model, args) { + var onchanges = this.data[model].onchanges || {}; + var record = args[1]; + var fields = args[2]; + if (!(fields instanceof Array)) { + fields = [fields]; + } + var result = {}; + _.each(fields, function (field) { + if (field in onchanges) { + var changes = _.clone(record); + onchanges[field](changes); + _.each(changes, function (value, key) { + if (record[key] !== value) { + result[key] = value; + } + }); + } + }); + return {value: result}; + }, + /** + * Simulate a 'read' operation. + * + * @private + * @param {string} model + * @param {Array} args + * @param {Object} _kwargs ignored... is that correct? + * @returns {Object} + */ + _mockRead: function (model, args, _kwargs) { + var self = this; + var ids = args[0]; + if (!_.isArray(ids)) { + ids = [ids]; + } + var fields = args[1] && args[1].length ? _.uniq(args[1].concat(['id'])) : Object.keys(this.data[model].fields); + var results = _.map(ids, function (id) { + var record = _.findWhere(self.data[model].records, {id: id}); + if (!record) { + throw "mock read: id does not exist..."; + } + var result = {}; + for (var i = 0; i < fields.length; i++) { + var field = self.data[model].fields[fields[i]]; + if (!field) { + // the field doens't exist on the model, so skip it + continue; + } + if (field.type === 'many2one') { + var relatedRecord = _.findWhere(self.data[field.relation].records, { + id: record[fields[i]] + }); + if (relatedRecord) { + result[fields[i]] = + [record[fields[i]], relatedRecord.display_name]; + } else { + result[fields[i]] = false; + } + } else { + result[fields[i]] = record[fields[i]]; + } + } + return result; + }); + return results; + }, + /** + * Simulate a 'read_group' call to the server. + * + * Note: most of the keys in kwargs are still ignored + * + * @private + * @param {string} model a string describing an existing model + * @param {Object} kwargs various options supported by read_group + * @param {string[]} kwargs.groupby fields that we are grouping + * @param {string[]} kwargs.fields fields that we are aggregating + * @param {Array} kwargs.domain the domain used for the read_group + * @param {boolean} kwargs.lazy still mostly ignored + * @param {integer} kwargs.limit ignored as well + * @returns {Object[]} + */ + _mockReadGroup: function (model, kwargs) { + if (!('lazy' in kwargs)) { + kwargs.lazy = true; + } + var self = this; + var fields = this.data[model].fields; + var aggregatedFields = _.clone(kwargs.fields); + var groupByField = kwargs.groupby[0]; + var result = []; + + // if no fields have been given, the server picks all stored fields + if (aggregatedFields.length === 0) { + aggregatedFields = _.keys(this.data[model].fields); + } + + // filter out non existing fields + aggregatedFields = _.filter(aggregatedFields, function (name) { + return name in self.data[model].fields; + }); + + + function aggregateFields(group, records) { + var type; + for (var i = 0; i < aggregatedFields.length; i++) { + type = fields[aggregatedFields[i]].type; + if (type === 'float' || type === 'integer') { + group[aggregatedFields[i]] = 0; + for (var j = 0; j < records.length; j++) { + group[aggregatedFields[i]] += records[j][aggregatedFields[i]]; + } + } + } + } + + var records = this._getRecords(model, kwargs.domain); + if (groupByField) { + var groupByFieldDescr = fields[groupByField.split(':')[0]]; + var groupByFunction, formatValue; + if (groupByFieldDescr.type === 'date') { + + var aggregateFunction = groupByField.split(':')[1] || 'month'; + + groupByField = groupByField.split(':')[0]; + groupByFunction = function (obj) { + if (aggregateFunction === 'day') { + return moment(obj[groupByField]).format('YYYY-MM-DD'); + } else { + return moment(obj[groupByField]).format('MMMM YYYY'); + } + }; + formatValue = function (val) { + if (aggregateFunction === 'day') { + return moment(val).format('YYYY-MM-DD'); + } else { + return moment(val).format('MMMM YYYY'); + } + }; + } else { + groupByFunction = function (obj) { + return obj[groupByField]; + }; + formatValue = function (val) { + return val instanceof Array ? val[0] : (val || false); + }; + } + _.each(_.groupBy(records, groupByFunction), function (g, val) { + val = formatValue(g[0][groupByField]); + var group = { + __domain: [[ + groupByField, "=", + val instanceof Array ? val[0] : (val || false) + ]].concat(kwargs.domain || []), + }; + var field = self.data[model].fields[groupByField]; + if (field.type === 'many2one' && !_.isArray(val)) { + var related_record = _.findWhere(self.data[field.relation].records, { + id: val + }); + if (related_record) { + group[groupByField] = [val, related_record.display_name]; + } else { + group[groupByField] = false; + } + } else { + group[groupByField] = val; + } + + // compute count key to match dumb server logic... + var countKey; + if (kwargs.lazy) { + countKey = groupByField + "_count"; + } else { + countKey = "__count"; + } + group[countKey] = g.length; + aggregateFields(group, g); + result.push($.extend(true, {}, group)); + }); + } else { + var group = { __count: records.length }; + aggregateFields(group, records); + result.push(group); + } + return result; + }, + /** + * Simulate a 'search_count' operation + * + * @private + * @param {string} model + * @param {Array} args + * @returns {integer} + */ + _mockSearchCount: function (model, args) { + return this._getRecords(model, args[0]).length; + }, + /** + * Simulate a 'search_read' operation on a model + * + * @private + * @param {Object} args + * @param {Array} args.domain + * @param {string} args.model + * @param {Array} [args.fields] defaults to the list of all fields + * @param {integer} [args.limit] + * @param {integer} [args.offset=0] + * @param {string[]} [args.sort] + * @returns {Object} + */ + _mockSearchRead: function (model, args, kwargs) { + var result = this._mockSearchReadController({ + model: model, + domain: args[0], + fields: args[1], + offset: args[2], + limit: args[3], + sort: args[4], + context: kwargs.context, + }); + return result.records; + }, + /** + * Simulate a 'search_read' operation, from the controller point of view + * + * @private + * @private + * @param {Object} args + * @param {Array} args.domain + * @param {string} args.model + * @param {Array} [args.fields] defaults to the list of all fields + * @param {integer} [args.limit] + * @param {integer} [args.offset=0] + * @param {string[]} [args.sort] + * @returns {Object} + */ + _mockSearchReadController: function (args) { + var self = this; + var records = this._getRecords(args.model, args.domain); + var fields = args.fields || _.keys(this.data[args.model].fields); + var nbRecords = records.length; + var offset = args.offset || 0; + records = records.slice(offset, args.limit ? (offset + args.limit) : nbRecords); + var processedRecords = _.map(records, function (r) { + var result = {}; + _.each(_.uniq(fields.concat(['id'])), function (fieldName) { + var field = self.data[args.model].fields[fieldName]; + if (field.type === 'many2one') { + var related_record = _.findWhere(self.data[field.relation].records, { + id: r[fieldName] + }); + result[fieldName] = + related_record ? [r[fieldName], related_record.display_name] : false; + } else { + result[fieldName] = r[fieldName]; + } + }); + return result; + }); + if (args.sort) { + var fieldName = args.sort.split(' ')[0]; + var order = args.sort.split(' ')[1]; + processedRecords.sort(function (r1, r2) { + if (r1[fieldName] < r2[fieldName]) { + return order === 'ASC' ? -1 : 1; + } + if (r1[fieldName] > r2[fieldName]) { + return order === 'ASC' ? 1 : -1; + } + return 0; + }); + } + var result = { + length: nbRecords, + records: processedRecords, + }; + return $.extend(true, {}, result); + }, + /** + * Simulate a 'unlink' operation + * + * @private + * @param {string} model + * @param {Array} args + * @returns {boolean} currently, always returns true + */ + _mockUnlink: function (model, args) { + var ids = args[0]; + if (!_.isArray(ids)) { + ids = [ids]; + } + this.data[model].records = _.reject(this.data[model].records, function (record) { + return _.contains(ids, record.id); + }); + + // update value of one2many fields pointing to the deleted records + _.each(this.data, function (d) { + var relatedFields = _.pick(d.fields, function (field) { + return field.type === 'one2many' && field.relation === model; + }); + _.each(Object.keys(relatedFields), function (relatedField) { + _.each(d.records, function (record) { + record[relatedField] = _.difference(record[relatedField], ids); + }); + }); + }); + + return true; + }, + /** + * Simulate a 'write' operation + * + * @private + * @param {string} model + * @param {Array} args + * @returns {boolean} currently, always return 'true' + */ + _mockWrite: function (model, args) { + _.each(args[0], this._writeRecord.bind(this, model, args[1])); + return true; + }, + /** + * Dispatch a RPC call to the correct helper function + * + * @see performRpc + * + * @private + * @param {string} route + * @param {Object} args + * @returns {Deferred<any>} + * Resolved with the result of the RPC. If the RPC should fail, the + * deferred should either be rejected or the call should throw an + * exception (@see performRpc for error handling). + */ + _performRpc: function (route, args) { + switch (route) { + case '/web/action/load': + return $.when(this._mockLoadAction(args)); + + case '/web/dataset/search_read': + return $.when(this._mockSearchReadController(args)); + } + if (route.indexOf('/web/image/') === 0) { + return $.when(); + } + switch (args.method) { + case 'copy': + return $.when(this._mockCopy(args.model, args.args[0])); + + case 'create': + return $.when(this._mockCreate(args.model, args.args[0])); + + case 'default_get': + return $.when(this._mockDefaultGet(args.model, args.args, args.kwargs)); + + case 'fields_get': + return $.when(this._mockFieldsGet(args.model, args.args)); + + case 'name_get': + return $.when(this._mockNameGet(args.model, args.args)); + + case 'name_create': + return $.when(this._mockNameCreate(args.model, args.args)); + + case 'name_search': + return $.when(this._mockNameSearch(args.model, args.args, args.kwargs)); + + case 'onchange': + return $.when(this._mockOnchange(args.model, args.args)); + + case 'read': + return $.when(this._mockRead(args.model, args.args, args.kwargs)); + + case 'read_group': + return $.when(this._mockReadGroup(args.model, args.kwargs)); + + case 'search_count': + return $.when(this._mockSearchCount(args.model, args.args)); + + case 'search_read': + return $.when(this._mockSearchRead(args.model, args.args, args.kwargs)); + + case 'unlink': + return $.when(this._mockUnlink(args.model, args.args)); + + case 'write': + return $.when(this._mockWrite(args.model, args.args)); + } + var model = this.data[args.model]; + if (model && typeof model[args.method] === 'function') { + return this.data[args.model][args.method](args.args, args.kwargs); + } + + console.error("Unimplemented route", route, args); + return $.when(); + }, + /** + * helper function: traverse a tree and apply the function f to each of its + * nodes. + * + * Note: this should be abstracted somewhere in web.utils, or in + * web.tree_utils + * + * @param {Object} tree object with a 'children' key, which contains an + * array of trees. + * @param {function} f + */ + _traverse: function (tree, f) { + var self = this; + if (f(tree)) { + _.each(tree.children, function (c) { self._traverse(c, f); }); + } + }, + /** + * Write a record. The main difficulty is that we have to apply x2many + * commands + * + * @private + * @param {string} model + * @param {Object} values + * @param {integer} id + */ + _writeRecord: function (model, values, id) { + var self = this; + var record = _.findWhere(this.data[model].records, {id: id}); + for (var field_changed in values) { + var field = this.data[model].fields[field_changed]; + var value = values[field_changed]; + if (!field) { + console.warn("Mock: Can't write on field '" + field_changed + "' on model '" + model + "' (field is undefined)"); + continue; + } + if (_.contains(['one2many', 'many2many'], field.type)) { + var ids = []; + // convert commands + _.each(value, function (command) { + if (command[0] === 0) { // CREATE + var id = self._mockCreate(field.relation, command[2]); + ids.push(id); + } else if (command[0] === 1) { // UPDATE + self._mockWrite(field.relation, [[command[1]], command[2]]); + } else if (command[0] === 2) { // DELETE + } else if (command[0] === 4) { // LINK_TO + ids.push(command[1]); + } else if (command[0] === 5) { // DELETE ALL + ids = []; + } else if (command[0] === 6) { // REPLACE WITH + ids = command[2]; + } else { + console.error('Command ' + JSON.stringify(command) + ' not supported by the MockServer'); + } + }); + record[field_changed] = ids; + } else if (field.type === 'many2one') { + if (value) { + var relatedRecord = _.findWhere(this.data[field.relation].records, { + id: value + }); + if (!relatedRecord) { + throw "Wrong id for a many2one"; + } else { + record[field_changed] = value; + } + } else { + record[field_changed] = false; + } + } else { + record[field_changed] = value; + } + } + }, +}); + +return MockServer; + +}); diff --git a/addons/web/static/tests/helpers/qunit_config.js b/addons/web/static/tests/helpers/qunit_config.js new file mode 100644 index 00000000000..aa679c60614 --- /dev/null +++ b/addons/web/static/tests/helpers/qunit_config.js @@ -0,0 +1,110 @@ +(function() { +"use strict"; + +/** + * QUnit Config + * + * The Odoo javascript test framework is based on QUnit (http://qunitjs.com/). + * This file is necessary to setup Qunit and to prepare its interactions with + * Odoo. It has to be loaded before any tests are defined. + * + * Note that it is not an Odoo module, because we want this code to be executed + * as soon as possible, not whenever the Odoo module system feels like it. + */ + + +/** + * This configuration variable is not strictly necessary, but it ensures more + * safety for asynchronous tests. With it, each test has to explicitely tell + * QUnit how many assertion it expects, otherwise the test will fail. + */ +QUnit.config.requireExpects = true; + +/** + * not important in normal mode, but in debug=assets, the files are loaded + * asynchroneously, which can lead to various issues with QUnit... Notice that + * this is done outside of odoo modules, otherwise the setting would not take + * effect on time. + */ +QUnit.config.autostart = false; + +/** + * A test timeout of 1 min, before an async test is considered failed. + */ +QUnit.config.testTimeout = 1 * 60 * 1000; + + +var sortButtonAppended = false; + +/** + * This is the way the testing framework knows that tests passed or failed. It + * only look in the phantomJS console and check if there is a ok or an error. + * + * Someday, we should devise a safer strategy... + */ +QUnit.done(function(result) { + if (!result.failed) { + console.log('ok'); + } else { + console.log('error'); + } + + if (!sortButtonAppended) { + _addSortButton(); + } +}); + +/** + * This logs various data in the console, which will be available in the log + * .txt file generated by the runbot. + */ +QUnit.log(function( result) { + if (!result.result) { + console.log('"' + result.name + '"', + 'in section', '"' + result.module + '"', + 'failed:', result.message); + } +}); + +/** + * This is done mostly for the .txt log file generated by the runbot. + */ +QUnit.moduleDone(function(result) { + if (!result.failed) { + console.log('"' + result.name + '"', "passed", result.total, "tests."); + } else { + console.log('"' + result.name + '"', + "failed", result.failed, + "tests out of", result.total, "."); + } + +}); + +/** + * Add a sort button on top of the QUnit result page, so we can see which tests + * take the most time. + */ +function _addSortButton() { + sortButtonAppended = true; + var $sort = $('<label> sort by time (desc)</label>').css({float: 'right'}); + $('h2#qunit-userAgent').append($sort); + $sort.click(function() { + var $ol = $('ol#qunit-tests'); + var $results = $ol.children('li').get(); + $results.sort(function (a, b) { + var timeA = Number($(a).find('span.runtime').first().text().split(" ")[0]); + var timeB = Number($(b).find('span.runtime').first().text().split(" ")[0]); + if (timeA < timeB) { + return 1; + } else if (timeA > timeB) { + return -1; + } else { + return 0; + } + }); + $.each($results, function(idx, $itm) { $ol.append($itm); }); + + }); +} + +})(); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js new file mode 100644 index 00000000000..442c67b1954 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils.js @@ -0,0 +1,442 @@ +odoo.define('web.test_utils', function (require) { +"use strict"; + +/** + * Test Utils + * + * In this module, we define various utility functions to help simulate a mock + * environment as close as possible as a real environment. The main function is + * certainly createView, which takes a bunch of parameters and give you back an + * instance of a view, appended in the dom, ready to be tested. + */ + +var AbstractField = require('web.AbstractField'); +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); +var MockServer = require('web.MockServer'); +var Widget = require('web.Widget'); + + +/** + * intercepts an event bubbling up the widget hierarchy. The event intercepted + * must be a "custom event", i.e. an event generated by the method 'trigger_up'. + * + * Note that this method really intercepts the event. It will not be propagated + * further, and even the handlers on the target will not fire. + * + * @param {Widget} widget the target widget (any Odoo widget) + * @param {string} eventName description of the event + * @param {function} fn callback executed when the even is intercepted + */ +function intercept(widget, eventName, fn) { + var _trigger_up = widget._trigger_up.bind(widget); + widget._trigger_up = function (event) { + if (event.name === eventName) { + fn(event); + } else { + _trigger_up(event); + } + }; +} + +/** + * logs all event going through the target widget. + * + * @param {Widget} widget + */ +function observe(widget) { + var _trigger_up = widget._trigger_up.bind(widget); + widget._trigger_up = function (event) { + console.log('%c[event] ' + event.name, 'color: blue; font-weight: bold;', event); + _trigger_up(event); + }; +} + +/** + * create a view synchronously. This method uses the createAsyncView method. + * Most views are synchronous, so the deferred can be resolved immediately and + * this method will work. + * + * Be careful, if for some reason a view is async, this method will crash. + * @see createAsyncView + * + * @param {Object} params will be given to createAsyncView + * @returns {AbstractView} + */ +function createView(params) { + var view; + createAsyncView(params).then(function (result) { + view = result; + }); + if (!view) { + throw "The view that you are trying to create is async. Please use createAsyncView instead"; + } + return view; +} + +/** + * create a view from various parameters. Here, a view means a javascript + * instance of an AbstractView class, such as a form view, a list view or a + * kanban view. + * + * It returns the instance of the view, properly created, with all rpcs going + * through a mock method using the data object as source, and already loaded/ + * started (with a do_search). The buttons/pager should also be created, if + * appropriate. + * + * Most views can be tested synchronously (@see createView), but some view have + * external dependencies (like lazy loaded libraries). In that case, it is + * necessary to use this method. + * + * @param {Object} params + * @param {string} params.arch the xml (arch) of the view to be instantiated + * @param {any[]} [params.domain] the initial domain for the view + * @param {Object} [params.context] the initial context for the view + * @param {Object} [params.debug=false] if true, the widget will be appended in + * the DOM. Also, the logLevel will be forced to 2 and the uncaught OdooEvent + * will be logged + * @param {string[]} [params.groupBy] the initial groupBy for the view + * @param {integer} [params.fieldDebounce=0] the debounce value to use for the + * duration of the test. + * @param {AbstractView} params.View the class that will be instantiated + * @param {string} params.model a model name, will be given to the view + * @param {Object} params.intercepts an object with event names as key, and + * callback as value. Each key,value will be used to intercept the event. + * Note that this is particularly useful if you want to intercept events going + * up in the init process of the view, because there are no other way to do it + * after this method returns + * @returns {Deferred<AbstractView>} resolves with the instance of the view + */ +function createAsyncView(params) { + var $target = $('#qunit-fixture'); + var widget = new Widget(); + + // handle debug parameter: render target, log stuff, ... + if (params.debug) { + $target = $('body'); + params.logLevel = 2; + observe(widget); + var separator = window.location.href.indexOf('?') !== -1 ? "&" : "?"; + var url = window.location.href + separator + 'testId=' + QUnit.config.current.testId; + console.log('%c[debug] debug mode activated', 'color: blue; font-weight: bold;', url); + $target.addClass('debug'); + } + + // add mock environment: mock server, session, fieldviewget, ... + var mockServer = addMockEnvironment(widget, params); + var viewInfo = mockServer.fieldsViewGet(params.arch, params.model); + + // create the view + var viewOptions = { + modelName: params.model || 'foo', + ids: 'res_id' in params ? [params.res_id] : undefined, + currentId: 'res_id' in params ? params.res_id : undefined, + domain: params.domain || [], + context: params.context || {}, + groupBy: params.groupBy || [], + }; + + _.extend(viewOptions, params.viewOptions); + + var view = new params.View(viewInfo, viewOptions); + + // make sure images do not trigger a GET on the server + $('#qunit-fixture').on('DOMNodeInserted.removeSRC', function () { + removeSrcAttribute($(this), widget); + }); + + // reproduce the DOM environment of views + var $web_client = $('<div>').addClass('o_web_client').appendTo($target); + var $control_panel = $('<div>').addClass('o_control_panel').appendTo($web_client); + var $content = $('<div>').addClass('o_content').appendTo($web_client); + var $view_manager = $('<div>').addClass('o_view_manager_content').appendTo($content); + + // make sure all Odoo events bubbling up are intercepted + if (params.intercepts) { + _.each(params.intercepts, function (cb, name) { + intercept(widget, name, cb); + }); + } + + return view.getController(widget).then(function (view) { + // override the view's 'destroy' so that it calls 'destroy' on the widget + // instead, as the widget is the parent of the view and the mockServer. + view.destroy = function () { + // remove the override to properly destroy the view and its children + // when it will be called the second time (by its parent) + delete view.destroy; + widget.destroy(); + $('#qunit-fixture').off('DOMNodeInserted.removeSRC'); + }; + return view.appendTo($view_manager).then(function () { + view.$el.on('click', 'a', function (ev) { + ev.preventDefault(); + }); + }).then(function () { + var $buttons = $('<div>'); + view.renderButtons($buttons); + $buttons.contents().appendTo($control_panel); + + var $sidebar = $('<div>'); + view.renderSidebar($sidebar); + $sidebar.contents().appendTo($control_panel); + + var $pager = $('<div>'); + view.renderPager($pager); + $pager.contents().appendTo($control_panel); + + return view; + }); + }); +} + +/** + * Add a mock environment to a widget. This helper function can simulate + * various kind of side effects, such as mocking RPCs, changing the session, + * or the translation settings. + * + * The simulated environment lasts for the lifecycle of the widget, meaning it + * disappears when the widget is destroyed. It is particularly relevant for the + * session mocks, because the previous session is restored during the destroy + * call. So, it means that you have to be careful and make sure that it is + * properly destroyed before another test is run, otherwise you risk having + * interferences between tests. + * + * @param {Widget} widget + * @param {Object} params + * @param {Object} [params.archs] a map of string [model,view_id,view_type] to + * a arch object. It is used to mock answers to 'load_views' custom events. + * This is useful when the widget instantiate a formview dialog that needs + * to load a particular arch. + * @param {string} [params.currentDate] a string representation of the current + * date. It is given to the mock server. + * @param {Object} params.data the data given to the created mock server. It is + * used to generate mock answers for every kind of routes supported by odoo + * @param {number} [params.logLevel] the log level. If it is 0, no logging is + * done, if 1, some light logging is done, if 2, detailed logs will be + * displayed for all rpcs. Most of the time, when working on a test, it is + * frequent to set this parameter to 2 + * @param {function} [params.mockRPC] a function that will be used to override + * the _performRpc method from the mock server. It is really useful to add + * some custom rpc mocks, or to check some assertions. + * @param {Object} [params.session] if it is given, it will be used as answer + * for all calls to this.getSession() by the widget, of its children. Also, + * it will be used to extend the current, real session. This side effect is + * undone when the widget is destroyed. + * @param {Object} [params.translateParameters] if given, it will be used to + * extend the core._t.database.parameters object. After the widget + * destruction, the original parameters will be restored. + * + * @returns {MockServer} the instance of the mock server, created by this + * function. It is necessary for createAsyncView so that method can call some + * other methods on it. + */ +function addMockEnvironment(widget, params) { + var Server = MockServer; + if (params.mockRPC) { + Server = MockServer.extend({_performRpc: params.mockRPC}); + } + var mockServer = new Server(params.data, { + logLevel: params.logLevel, + currentDate: params.currentDate, + }); + var widgetDestroy; + + // make sure the debounce value for input fields is set to 0 + var initialDebounce = AbstractField.prototype.DEBOUNCE; + AbstractField.prototype.DEBOUNCE = params.fieldDebounce || 0; + + if ('session' in params) { + var initialSession = _.extend({}, session); + _.extend(session, params.session); + widgetDestroy = widget.destroy; + widget.destroy = function () { + for (var key in session) { + delete session[key]; + } + _.extend(session, initialSession); + widgetDestroy.call(this); + }; + } + + if ('config' in params) { + var initialConfig = _.extend({}, config); + _.extend(config, params.config); + widgetDestroy = widget.destroy; + widget.destroy = function () { + for (var key in config) { + delete config[key]; + } + _.extend(config, initialConfig); + AbstractField.prototype.DEBOUNCE = initialDebounce; + widgetDestroy.call(this); + }; + } + + if ('translateParameters' in params) { + var initialParameters = _.extend({}, core._t.database.parameters); + _.extend(core._t.database.parameters, params.translateParameters); + var oldDestroy = widget.destroy; + widget.destroy = function () { + for (var key in core._t.database.parameters) { + delete core._t.database.parameters[key]; + } + _.extend(core._t.database.parameters, initialParameters); + oldDestroy.call(this); + }; + } + + + intercept(widget, 'call_service', function (event) { + if (event.data.service === 'ajax') { + var result = mockServer.performRpc(event.data.args[0], event.data.args[1]); + event.data.callback(result); + } + }); + + intercept(widget, "load_views", function (event) { + if (params.logLevel === 2) { + console.log('[mock] load_views', event.data); + } + var views = {}; + var model = event.data.modelName; + _.each(event.data.views, function (view_descr) { + var view_id = view_descr[0] || false; + var view_type = view_descr[1]; + var key = [model, view_id, view_type].join(','); + var arch = params.archs[key]; + if (!arch) { + throw new Error('No arch found for key ' + key); + } + views[view_type] = mockServer.fieldsViewGet(arch, model); + }); + + event.data.on_success(views); + }); + + intercept(widget, "get_session", function (event) { + event.data.callback(params.session || session); + }); + + intercept(widget, "load_filters", function (event) { + if (params.logLevel === 2) { + console.log('[mock] load_filters', event.data); + } + event.data.on_success([]); + }); + + return mockServer; +} + +/** + * create a model from given parameters. + * + * @param {Object} params This object will be given to addMockEnvironment, so + * any parameters from that method applies + * @param {Class} params.Model the model class to use + * @returns {Model} + */ +function createModel(params) { + var widget = new Widget(); + + var model = new params.Model(widget); + + addMockEnvironment(widget, params); + + return model; +} + +/** + * simulate a drag and drop operation between 2 jquery nodes: $el and $to. + * This is a crude simulation, with only the mousedown, mousemove and mouseup + * events, but it is enough to help test drag and drop operations with jqueryUI + * sortable. + * + * @param {jqueryElement} $el + * @param {jqueryElement} $to + */ +function dragAndDrop($el, $to) { + var elementCenter = $el.offset(); + elementCenter.left += $el.outerWidth()/2; + elementCenter.top += $el.outerHeight()/2; + + var toCenter = $to.offset(); + toCenter.left += $to.outerWidth()/2; + toCenter.top += $to.outerHeight()/2; + + $el.trigger($.Event("mousedown", { + which: 1, + pageX: elementCenter.left, + pageY: elementCenter.top + })); + + $el.trigger($.Event("mousemove", { + which: 1, + pageX: toCenter.left, + pageY: toCenter.top + })); + + $el.trigger($.Event("mouseup", { + which: 1, + pageX: toCenter.left, + pageY: toCenter.top + })); +} + +/** + * simulate a mouse event with a custom event who add the item position. This is + * sometimes necessary because the basic way to trigger an event (such as + * $el.trigger('mousemove')); ) is too crude for some uses. + * + * @param {jqueryElement} $el + * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' + */ +function triggerMouseEvent($el, type) { + var pos = $el.offset(); + var e = new jQuery.Event(type); + e.pageX = pos.left; + e.pageY = pos.top; + e.which = 1; + $el.trigger(e); +} + +/** + * Removes the src attribute on images and iframes to prevent not found errors, + * and optionally triggers an rpc with the src url as route on a widget. + * + * @param {JQueryElement} $el + * @param {[Widget]} widget the widget on which the rpc should be performed + */ +function removeSrcAttribute($el, widget) { + $el.find('img, iframe[src]').each(function () { + var src = $(this).attr('src'); + if (src[0] !== '#') { + $(this).attr('src', '#test:' + $(this).attr('src')); + if (widget) { + widget._rpc({route: src}); + } + } + }); +} + +return session.is_bound.then(function () { + setTimeout(function () { + // this is done with the hope that tests are + // only started all together... + QUnit.start(); + }, 0); + return { + intercept: intercept, + observe: observe, + createView: createView, + createAsyncView: createAsyncView, + createModel: createModel, + addMockEnvironment: addMockEnvironment, + dragAndDrop: dragAndDrop, + triggerMouseEvent: triggerMouseEvent, + removeSrcAttribute: removeSrcAttribute, + }; +}); + +}); 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 00000000000..15947103e6e --- /dev/null +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -0,0 +1,1220 @@ +odoo.define('web.basic_model_tests', function (require) { +"use strict"; + +var BasicModel = require('web.BasicModel'); +var testUtils = require('web.test_utils'); + +var createModel = testUtils.createModel; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + display_name: {string: "STRING", type: 'char'}, + 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'}, + }, + 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: { + 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"}, + date: {string: "Date Field", type: 'date'}, + }, + records: [ + {id: 12, display_name: "gold", date: "2017-01-25"}, + {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 = { + res_id: 2, + modelName: 'partner', + fields: this.data.partner.fields, + }; + } +}, function () { + QUnit.module('BasicModel'); + + QUnit.test('simple functionality', function (assert) { + assert.expect(6); + + this.params.fieldNames = ['foo']; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + assert.strictEqual(model.get(1), null, "should return null for non existing key"); + + model.load(this.params).then(function (resultID) { + // 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('notify change with many2one', function (assert) { + assert.expect(2); + + this.params.fieldNames = ['foo', 'qux']; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.qux, false, "qux field should be false"); + 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('write on a many2one', function (assert) { + assert.expect(4); + var self = this; + + this.params.fieldNames = ['product_id']; + + var rpcCount = 0; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xpad', + "should be initialized with correct value"); + + 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"); + + 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', function (assert) { + assert.expect(5); + + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + }; + + this.params.fieldNames = ['foo', 'bar']; + this.params.context = {hello: 'world'}; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + var context = args.args[4]; + assert.deepEqual(context, {hello: 'world'}, + "context should be sent by the onchange"); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + 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"); + + 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', function (assert) { + assert.expect(5); + + 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 = 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); + }, + }); + + model.load(this.params).then(function (resultID) { + 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"); + + 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('notifyChange on a many2one, without display_name', function (assert) { + assert.expect(3); + + this.params.fieldNames = ['product_id']; + + var model = 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); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xpad', + "product_id field is set to xpad"); + + 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', function (assert) { + assert.expect(2); + + this.data.partner.onchanges.foo = function (obj) { + obj.foo = obj.foo + " alligator"; + }; + + this.params.fieldNames = ['foo', 'product_id']; + + var model = 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); + }, + }); + + model.load(this.params).then(function (resultID) { + 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', function (assert) { + assert.expect(2); + this.data.partner.records[0].product_id = false; + 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 = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.product_id, false, "product_id is not set"); + + 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', 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 = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + 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', function (assert) { + assert.expect(2); + + this.params.fieldsInfo = { + default: { + category: { + fieldsInfo: {default: {date: {}}}, + relatedFields: {date: {type: "date"}}, + viewType: 'default', + }, + }, + }; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + var changes = { + category: {operation: 'ADD_M2M', ids: [{id: 12}]} + }; + model.notifyChanges(resultID, changes).then(function () { + 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('can fetch a list', function (assert) { + assert.expect(4); + + this.params.fieldNames = ['foo']; + this.params.domain = []; + this.params.groupedBy = []; + this.params.res_id = undefined; + + var rpcCount = 0; + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + 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"); + assert.strictEqual(rpcCount, 1, "should have done 1 rpc (searchread)"); + }); + model.destroy(); + }); + + QUnit.test('fetch x2manys in list, with not too many rpcs', function (assert) { + assert.expect(2); + + 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 rpcCount = 0; + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + + assert.strictEqual(record.data[0].data.category.data.length, 2, + "first record should have 2 categories loaded"); + assert.strictEqual(rpcCount, 2, "should have done 2 rpc (searchread and read category)"); + }); + model.destroy(); + }); + + QUnit.test('can make a default_record, no onchange', function (assert) { + assert.expect(3); + + this.params.context = {}; + this.params.fieldNames = ['product_id', 'category', 'product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + 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 []"); + }); + model.destroy(); + }); + + QUnit.test('default_record, with onchange on many2one', 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 = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return $.when({value: { product_id: false }}); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + 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', 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 = 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', + }; + + model.load(params).then(function (resultID) { + 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, 2, "should have done 2 rpcs: default_get and 1 name_get"); + }); + model.destroy(); + }); + + QUnit.test('undoing a change makes the record not dirty', function (assert) { + assert.expect(4); + + this.params.fieldNames = ['foo']; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + 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"); + model.notifyChanges(resultID, {foo: "hello"}); + assert.ok(model.isDirty(resultID), "record should be dirty"); + model.notifyChanges(resultID, {foo: "gnap"}); + assert.ok(!model.isDirty(resultID), "record should not be dirty"); + }); + model.destroy(); + }); + + QUnit.test('isDirty works correctly on list made empty', function (assert) { + assert.expect(3); + + this.params.fieldNames = ['category']; + this.params.res_id = 1; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + 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"); + model.notifyChanges(resultID, {category: { + operation: 'REMOVE', + id: category_value.data[0].id + }}); + assert.ok(model.isDirty(resultID), "record should be considered dirty"); + }); + model.destroy(); + }); + + QUnit.test('can duplicate a record', function (assert) { + assert.expect(4); + + this.params.fieldNames = ['foo']; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + 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"); + model.duplicateRecord(resultID).then(function (duplicateID) { + 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', function (assert) { + assert.expect(3); + + this.params.fieldNames = ['product_id']; + + var self = this; + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.product_id.data.display_name, 'xpad', "product_id should be set"); + model.notifyChanges(resultID, {product_id: false}); + + record = model.get(resultID); + assert.strictEqual(record.data.product_id, false, "product_id should not be set"); + + 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', function (assert) { + assert.expect(9); + + 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 = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === '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[0].product_id_count = 0; + return result; + }); + } + return this._super.apply(this, arguments); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.length, 2, "should have 2 groups"); + var groupID = record.data[0].id; + 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; + + 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; + 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('create record, then save', function (assert) { + assert.expect(4); + + this.params.fieldNames = ['product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var id; + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'create') { + // has to be done before the call to _super + assert.notOk('product_ids' in args.args[0], "should not have any value"); + assert.notOk('category' in args.args[0], "should not have other fields"); + } + var result = this._super(route, args); + if (args.method === 'create') { + result.then(function (res) { + id = res; + }); + } + return result; + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + 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', function (assert) { + assert.expect(4); + + this.data.partner.records[1].product_ids = [37]; + + this.params.fieldNames = ['product_ids']; + + var model = 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); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID, {raw: true}); + assert.deepEqual(record.data.product_ids, [37], "should have correct initial value"); + + model.makeRecord('product', [{ + name: 'name', + string: "Product Name", + type: "char", + value: "xpod" + } + ]).then(function (relatedRecordID) { + model.notifyChanges(record.id, { + product_ids: {operation: "ADD", id: relatedRecordID} + }); + model.save(record.id); + }); + }); + model.destroy(); + }); + + QUnit.test('onchange with a one2many on a new record', function (assert) { + assert.expect(4); + + this.data.partner.fields.total.default = 50; + 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'; + + var model = 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, false, {name: "xpod"}]], + "Should have sent the create command in the onchange"); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + + var record = model.get(resultID); + assert.strictEqual(record.data.product_ids.data.length, 0, + "one2many should start with a list of length 0"); + + model.makeRecord('product', [{ + name: 'name', + string: "Product Name", + type: "char", + value: "xpod" + } + ]).then(function (relatedRecordID) { + 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)', function (assert) { + assert.expect(2); + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['date'], + fields: this.data.partner.fields, + modelName: 'partner', + res_id: 1, + }; + + 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; + + 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)', function (assert) { + assert.expect(2); + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['date'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'list', + }; + + 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)', function (assert) { + assert.expect(1); + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['date'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'record', + }; + + 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', function (assert) { + assert.expect(1); + + this.data.partner.fields.category.default = [12, 14]; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['category'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'record', + }; + + 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('contexts and domains can be properly fetched', 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 = createModel({ + Model: BasicModel, + data: this.data, + }); + + this.params.fieldNames = ['product_id', 'foo']; + + model.load(this.params).then(function (resultID) { + 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 = 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]]", + }, + } + }; + + model.load(this.params).then(function (resultID) { + var recordPartner = model.get(resultID); + assert.deepEqual( + recordPartner.getContext({fieldName: "product_id"}), + {hello2: "world", test2: "gnap"}, + "field context should have been overriden by xml attribute"); + assert.deepEqual( + recordPartner.getDomain({fieldName: "product_id"}), + [["hello2", "like", "world"], ["test2", "like", "gnap"]], + "field domain should have been overriden by xml attribute"); + }); + model.destroy(); + }); + + QUnit.test('dont write on readonly fields (except if create)', function (assert) { + assert.expect(6); + + this.params.fieldNames = ['foo', 'bar']; + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + }; + this.data.partner.fields.bar.readonly = true; + + var model = 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", + bar: 21, + }, "should also save bar field in create mode"); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.bar, 2, + "should be initialized with correct value"); + + model.notifyChanges(resultID, {foo: "verylongstring"}); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 14, + "should be changed with correct value"); + + model.save(resultID); + }); + + // start again, but with a new record + delete this.params.res_id; + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.bar, false, + "should be initialized with correct value"); + + model.notifyChanges(resultID, {foo: "anotherverylongstring"}); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 21, + "should be changed with correct value"); + + model.save(resultID); + }); + model.destroy(); + }); + + QUnit.test('default_get with one2many values', function (assert) { + assert.expect(1); + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + 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', + }; + model.load(params).then(function (resultID) { + assert.strictEqual(typeof resultID, 'string', "result should be a valid id"); + }); + model.destroy(); + }); + + QUnit.test('get method works on unset x2many, after save without reload', function (assert) { + assert.expect(3); + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['product_ids'], + fields: this.data.partner.fields, + modelName: 'partner', + type: 'record', + }; + + model.load(params).then(function (resultID) { + assert.ok(model.isDirty(resultID), "datapoint should be dirty"); + model.save(resultID, {reload: false}); + var record = model.get(resultID, {raw: true}); + assert.deepEqual(record.data.product_ids, [], + "should have correct value for product_ids field"); + assert.notOk(model.isDirty(resultID), "datapoint should not be dirty"); + }); + model.destroy(); + }); + + QUnit.test('call makeRecord with a pre-fetched many2one field', function (assert) { + assert.expect(3); + var rpcCount = 0; + + var model = 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', function (assert) { + assert.expect(5); + var rpcCount = 0; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + model.makeRecord('coucou', [{ + name: 'partner_ids', + fields: [{ + name: 'id', + type: 'integer', + }, { + name: 'display_name', + type: 'char', + }], + relation: 'partner', + type: 'many2many', + value: [1, 2], + }]).then(function (recordID) { + 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', function (assert) { + assert.expect(5); + var rpcCount = 0; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + + 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", + }], + }]).then(function (recordID) { + 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(); + }); + +});}); 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 00000000000..33e989c5cee --- /dev/null +++ b/addons/web/static/tests/views/calendar_tests.js @@ -0,0 +1,340 @@ +odoo.define('web.calendar_tests', function (require) { +"use strict"; + +var CalendarView = require('web.CalendarView'); +var testUtils = require('web.test_utils'); +var session = require('web.session'); + +var createView = testUtils.createView; + +var initialDate = new Date("2016-12-12T08:00:00Z"); + + +QUnit.module('Views', { + beforeEach: function () { + session.uid = -1; // TO CHECK + this.data = { + event: { + fields: { + id: {string: "ID", type: "integer"}, + user_id: {string: "user", type: "many2one", relation: 'user'}, + parnter_id: {string: "user", type: "many2one", relation: 'partner', related: 'user_id.parnter_id'}, + name: {string: "name", type: "char"}, + start: {string: "start", type: "datetime"}, + stop: {string: "stop", type: "datetime"}, + allday: {string: "allday", type: "boolean"}, + partner_ids: {string: "attendees", type: "one2many", relation: 'partner'}, + type: {string: "type", type: "integer"}, + }, + 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} + ], + check_access_rights: function () { + return $.when(true); + } + }, + user: { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + parnter_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'} + ] + }, + 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} + ] + }, + }; + } +}, function () { + + QUnit.module('CalendarView'); + + var archs = { + "event,false,form": { + attrs: {}, + children: [ + { + attrs: {name: "name"}, + children: [], + tag: 'field' + }, + { + attrs: {name: "allday"}, + children: [], + tag: 'field' + }, + { + attrs: {name: "start"}, + children: [], + tag: 'field' + }, + { + attrs: {name: "stop"}, + children: [], + tag: 'field' + } + ], + tag: "form" + }, + "event,1,form": { + attrs: {}, + children: [ + { + attrs: { + modifiers: '{"invisible": true}', + name: "allday" + }, + children: [], + tag: 'field' + }, + { + attrs: { + modifiers: '{"invisible": [["allday","=",false]]}', + name: "start" + }, + children: [], + tag: 'field' + }, + { + attrs: { + modifiers: '{"invisible": [["allday","=",true]]}', + name: "stop" + }, + children: [], + tag: 'field' + } + ], + tag: "form" + } + }; + + QUnit.test('simple calendar rendering', function (assert) { + assert.expect(19); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'scale_zoom="week" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="week" '+ + 'attendee="partner_ids" '+ + 'color="parnter_id">'+ + '<field name="name"/>'+ + '<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, + }, + }); + + assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length, "should instance of fullcalendar"); + + var $sidebar = calendar.$('.o_calendar_sidebar'); + + assert.strictEqual($sidebar.find('.ui-state-active').text(), "12", "should highlight the target day"); + + // test view scales + + assert.strictEqual(calendar.$('.fc-event').length, 9, "should display 9 events on the week (4 event + 5 days event)"); + assert.strictEqual($sidebar.find('.o_selected_range').length, 7, "week scale should highlight 7 days in mini calendar"); + + calendar.$buttons.find('.o_calendar_button_day').trigger('click'); // display only one day + assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the day"); + assert.strictEqual($sidebar.find('.o_selected_range').length, 1, "should highlight the target day in mini calendar"); + + calendar.$buttons.find('.o_calendar_button_month').trigger('click'); // display all the month + assert.strictEqual(calendar.$('.fc-event').length, 6, "should display 6 events on the month (5 events + 2 week event - 1 'event 6' is filtered)"); + assert.strictEqual($sidebar.find('.o_selected_range').length, 31, "month scale should highlight all days in mini calendar"); + + // test filters + + assert.strictEqual($sidebar.find('.o_calendar_filter').length, 2, "should display 3 filters"); + + var $typeFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(user))'); + assert.ok($typeFilter.length, "should display 'user' filter"); + assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 1, "should display 1 filter items for 'user'"); + + var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(attendees))'); + assert.ok($attendeesFilter.length, "should display 'attendees' filter"); + assert.strictEqual($attendeesFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)"); + assert.ok($attendeesFilter.find('.o_form_field_many2one').length, "should display one2many search bar for 'attendees' filter"); + + // test search bar in filter + + $sidebar.find('input.o_form_input').trigger('click'); + 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 + $('ul.ui-autocomplete li:first').trigger('click'); + assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 4, "should display 4 filter items for 'attendees'"); + $sidebar.find('input.o_form_input').trigger('click'); + 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 + $sidebar.find('.o_calendar_filter_item[data-id="1"] .o_remove').trigger('click'); + assert.ok($('.modal button.btn:contains(Ok)').length, "should display the confirm message"); + $('.modal button.btn:contains(Ok)').trigger('click'); + assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 3, "click on remove then should display 3 filter items for 'attendees'"); + calendar.destroy(); + }); + + QUnit.test('create and change events', function (assert) { + assert.expect(19); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'scale_zoom="week" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month" '+ + 'readonly_form_view_id="1">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.ok(calendar.$('.fc-month-view').length, "should display in month mode"); + + // click on an existing event to open the formViewDialog + + calendar.$('.fc-event:contains(event 4) .fc-content').trigger('click'); + + assert.ok($('.modal-body').length, "should open the form view in dialog when click on event"); + assert.ok($('.modal button.btn:contains(Edit)').length, "formViewDialog should be in readonly mode"); + assert.ok($('.modal button.btn:contains(Delete)').length, "formViewDialog should have a delete button"); + + $('.modal button.btn:contains(Edit)').trigger('click'); + + assert.ok($('.modal-body').length, "should switch the modal in edit mode"); + assert.notOk($('.modal button.btn:contains(Delete)').length, "formViewDialog should not have a delete button in edit mode"); + + $('.modal-body input:first').val('event 4 modified').trigger('input'); + $('.modal button.btn:contains(Save)').trigger('click'); + + assert.notOk($('.modal-body').length, "save button should close the modal"); + assert.ok(calendar.$('.fc-event:contains(event 4 modified)').length, "should display the updated records"); + + // create a new event, quick create only + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + + assert.ok($('.modal-dialog.modal-sm').length, "should open the quick create dialog"); + + $('.modal-body input:first').val('new event in quick create').trigger('input'); + $('.modal button.btn:contains(Create)').trigger('click'); + + assert.strictEqual(calendar.$('.fc-event:contains(new event in quick create)').length, 1, "should display the new record"); + assert.strictEqual(calendar.$('td.fc-event-container[colspan]').length, 2, "should the new record have only one day"); + + // create a new event with 2 days + + $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell.next(), "mousemove"); + testUtils.triggerMouseEvent($cell.next(), "mouseup"); + + $('.modal input:first').val('new event in quick create 2').trigger('input'); + $('.modal button.btn:contains(Edit)').trigger('click'); + + assert.strictEqual($('.modal-lg input:first').val(), 'new event in quick create 2', "should open the formViewDialog with default values"); + + $('.modal-lg button.btn:contains(Save)').trigger('click'); + + 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.strictEqual($newevent2.closest('.fc-event-container').attr('colspan'), "2", "the new record should have 2 days"); + + // delete the a record + + calendar.$('.fc-event:contains(event 4) .fc-content').trigger('click'); + $('.modal button.btn:contains(Delete)').trigger('click'); + assert.ok($('.modal button.btn:contains(Ok)').length, "should display the confirm message"); + $('.modal button.btn:contains(Ok)').trigger('click'); + assert.notOk(calendar.$('.fc-event:contains(event 4) .fc-content').length, "the record should be deleted"); + + assert.strictEqual(calendar.$('.fc-event-container .fc-event').length, 8, "should display 8 events"); + // move to next month + calendar.$buttons.find('.o_calendar_button_next').click(); + + assert.strictEqual(calendar.$('.fc-event-container .fc-event').length, 0, "should display 0 events"); + calendar.destroy(); + }); + + QUnit.test('rendering, with many2many', function (assert) { + assert.expect(1); + + this.data.event.fields.partner_ids.type = 'many2many'; + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday"> '+ + '<field name="partner_ids" avatar_field="image" write_model="filter_partner" write_field="partner_id"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.strictEqual(calendar.$('.o_calendar_filter_items .o_cal_avatar').length, 3, + "should have 3 avatars in the side bar"); + + calendar.destroy(); + }); +}); + +}); 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 00000000000..fdd5fd84c64 --- /dev/null +++ b/addons/web/static/tests/views/form_tests.js @@ -0,0 +1,2688 @@ +odoo.define('web.form_tests', function (require) { +"use strict"; + +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var FormView = require('web.FormView'); +var testUtils = require('web.test_utils'); + +var _t = core._t; +var createView = testUtils.createView; +var createAsyncView = testUtils.createAsyncView; + +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"}, + }, + 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", + }], + onchanges: {}, + }, + product: { + fields: { + 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: "int"}, + }, + records: [ + {id: 12, display_name: "gold", color: 2}, + {id: 14, display_name: "silver", color: 5}, + ] + }, + }; + } +}, function () { + + QUnit.module('FormView'); + + QUnit.test('simple form rendering', function (assert) { + assert.expect(10); + + var form = 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>' + + '<field name="foo"/>' + + '<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.strictEqual(form.$('div.test').length, 1, + "should contain a div with some html"); + assert.strictEqual(form.$('div.test').css('opacity'), "0.5", + "should keep the inline style on html elements"); + assert.strictEqual(form.$('label:contains(Foo)').length, 1, + "should contain label Foo"); + assert.strictEqual(form.$('span:contains(blip)').length, 1, + "should contain span with field value"); + + assert.strictEqual(form.$('label:contains(something_id)').length, 0, + "should not contain f3 string description"); + assert.strictEqual(form.$('label:contains(f3_description)').length, 1, + "should contain custom f3 string description"); + assert.strictEqual(form.$('div.o_field_one2many table').length, 1, + "should render a one2many relation"); + + assert.strictEqual(form.$('tbody td:not(.o_list_record_selector) .o_checkbox input:checked').length, 1, + "1 checkboxes should be checked"); + + assert.strictEqual(form.get('title'), "second record", + "title should be display_name of record"); + assert.strictEqual(form.$('label.o_form_label_empty:contains(timmy)').length, 0, + "the many2many label shouldn't be marked as empty"); + form.destroy(); + }); + + QUnit.test('only necessary fields are fetched', function (assert) { + assert.expect(1); + + var form = 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"); + return this._super(route, args); + } + }); + form.destroy(); + }); + + QUnit.test('group rendering', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('table.o_inner_group').length, 1, + "should contain an inner group"); + form.destroy(); + }); + + QUnit.test('invisible fields are not rendered', function (assert) { + assert.expect(3); + + var form = 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"/>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('label.o_form_invisible:contains(Foo)').length, 1, + "should not contain label Foo"); + assert.strictEqual(form.$('span.o_form_invisible:contains(yop)').length, 1, + "should not contain span with field value"); + assert.strictEqual(form.$('.o_form_field.o_form_invisible:contains(0.4)').length, 1, + "field qux should be invisible"); + form.destroy(); + }); + + QUnit.test('invisible elements are properly hidden', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<group string="invgroup" invisible="1">' + + '<field name="foo"/>' + + '</group>' + + '</group>' + + '<notebook>' + + '<page string="visible">' + + '</page>' + + '<page string="invisible" invisible="1">' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_notebook li.o_form_invisible a:contains(invisible)').length, 1, + "should not display tab invisible"); + assert.strictEqual(form.$('table.o_inner_group.o_form_invisible td:contains(invgroup)').length, 1, + "should not display invisible groups"); + form.destroy(); + }); + + QUnit.test('invisible attrs are re-evaluated on field changed', 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 = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="product_id" invisible="1"/>' + + '<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, + }); + + + form.$buttons.find('.o_form_button_edit').click(); + assert.ok(form.$('.foo_field').hasClass('o_form_invisible'), 'should not display foo field'); + assert.ok(form.$('.bar_field').hasClass('o_form_invisible'), 'should not display bar field'); + + // set a value on the m2o + var $dropdown = form.$('.o_form_field_many2one input').autocomplete('widget'); + form.$('.o_form_field_many2one input').click(); + $dropdown.find('li:first()').click(); + assert.ok(!form.$('.foo_field').hasClass('o_form_invisible'), 'should not display foo field'); + form.destroy(); + }); + + QUnit.test('invisible attrs on first notebook page', function (assert) { + assert.expect(6); + + var form = 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, + }); + + form.$buttons.find('.o_form_button_edit').click(); + assert.ok(form.$('.o_notebook .nav li:first()').hasClass('active'), + 'first tab should be active'); + assert.ok(!form.$('.o_notebook .nav li:first()').hasClass('o_form_invisible'), + 'first tab should be visible'); + + // set a value on the m2o + var $dropdown = form.$('.o_form_field_many2one input').autocomplete('widget'); + form.$('.o_form_field_many2one input').click(); + $dropdown.find('li:first()').click(); + assert.ok(!form.$('.o_notebook .nav li:first()').hasClass('active'), + 'first tab should not be active'); + assert.ok(form.$('.o_notebook .nav li:first()').hasClass('o_form_invisible'), + 'first tab should be invisible'); + assert.ok(form.$('.o_notebook .nav li:nth(1)').hasClass('active'), + 'second tab should be active'); + assert.ok(form.$('.o_notebook .tab-content .tab-pane:nth(1)').hasClass('active'), + 'second page should be active'); + form.destroy(); + }); + + QUnit.test('invisible attrs on group', function (assert) { + assert.expect(2); + + var form = 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 + }); + + assert.strictEqual(form.$('div.o_group:visible').length, 1, "should display the group"); + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_boolean input').click(); + assert.strictEqual(form.$('div.o_group:hidden').length, 1, "should not display the group"); + form.destroy(); + }); + + QUnit.test('invisible attrs with zero value in domain and unset value in data', function (assert) { + assert.expect(1); + + this.data.partner.fields.int_field.type = 'monetary'; + + var form = 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.notOk(form.$('div.hello').is(':visible'), + "attrs invisible should have been computed and applied"); + form.destroy(); + }); + + QUnit.test('rendering stat buttons', function (assert) { + assert.expect(3); + + var form = 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.strictEqual(form.$('button.oe_stat_button').length, 2, + "should have 2 stat buttons"); + assert.strictEqual(form.$('button.oe_stat_button.o_form_invisible').length, 1, + "should have 1 invisible stat buttons"); + + var count = 0; + testUtils.intercept(form, "execute_action", function () { + count++; + }); + form.$('.oe_stat_button').first().click(); + assert.strictEqual(count, 1, "should have triggered a execute action"); + form.destroy(); + }); + + + QUnit.test('label uses the string attribute', function (assert) { + assert.expect(1); + + var form = 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.strictEqual(form.$('label.o_form_label:contains(customstring)').length, 1, + "should have 1 label with correct string"); + form.destroy(); + }); + + + QUnit.test('empty fields have o_form_empty class in readonly mode', function (assert) { + assert.expect(4); + + 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.trululu.readonly = true; + this.data.partner.fields.int_field.readonly = true; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '<field name="trululu"/>' + + '<field name="int_field"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_form_field.o_form_field_empty').length, 2, + "should have 2 empty fields with correct class"); + assert.strictEqual(form.$('.o_form_label_empty').length, 2, + "should have 2 muted labels (for the empty fieds) in readonly"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_form_field_empty').length, 1, + "in edit mode, only readonly fields should have .o_form_field_empty class"); + assert.strictEqual(form.$('.o_form_label_empty').length, 1, + "in edit mode, only readonly fields should have .o_form_label_empty class"); + form.destroy(); + }); + + QUnit.test('empty inner readonly fields don\'t have o_form_empty class in "create" mode', function (assert) { + assert.expect(2); + + this.data.partner.fields.product_id.readonly = true; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<group>' + + '<field name="product_id"/>' + + '</group>' + + '</group>' + + '</sheet>' + + '</form>', + }); + assert.strictEqual(form.$('.o_form_label_empty').length, 0, + "no empty class on label"); + assert.strictEqual(form.$('.o_form_field_empty').length, 0, + "no empty class on field"); + form.destroy(); + }); + + QUnit.test('form view can switch to edit mode', function (assert) { + assert.expect(9); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), + 'form view should have .o_form_readonly'); + assert.ok(form.$buttons.find('.o_form_buttons_view').is(':visible'), + 'readonly buttons should be visible'); + assert.ok(!form.$buttons.find('.o_form_buttons_edit').is(':visible'), + 'edit buttons should not be visible'); + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode'); + assert.ok(form.$el.hasClass('o_form_editable'), + 'form view should have .o_form_editable'); + assert.ok(!form.$el.hasClass('o_form_readonly'), + 'form view should not have .o_form_readonly'); + assert.ok(!form.$buttons.find('.o_form_buttons_view').is(':visible'), + 'readonly buttons should not be visible'); + assert.ok(form.$buttons.find('.o_form_buttons_edit').is(':visible'), + 'edit buttons should be visible'); + form.destroy(); + }); + + QUnit.test('required fields should have o_form_required in readonly mode', function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.required = true; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('span.o_form_required').length, 1, + "should have 1 span with o_form_required class"); + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input.o_form_required').length, 1, + "in edit mode, should have 1 input with o_form_required"); + form.destroy(); + }); + + QUnit.test('separators', function (assert) { + assert.expect(1); + + var form = 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.strictEqual(form.$('div.o_horizontal_separator').length, 1, + "should contain a separator div"); + form.destroy(); + }); + + QUnit.test('buttons in form view', function (assert) { + assert.expect(7); + + var rpcCount = 0; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="state" invisible="1"/>' + + '<header>' + + '<button name="post" class="p" states="ab,ef" 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>', + res_id: 2, + mockRPC: function () { + rpcCount++; + return this._super.apply(this, arguments); + }, + }); + assert.strictEqual(form.$('button.btn.btn-sm div.fa.fa-check').length, 1, + "should contain a button with correct content"); + + assert.strictEqual(form.$('.o_form_statusbar button').length, 2, + "should have 2 buttons in the statusbar"); + + assert.strictEqual(form.$('.o_form_statusbar button:visible').length, 1, + "should have only 1 visible button in the statusbar"); + + testUtils.intercept(form, 'execute_action', function (event) { + assert.strictEqual(event.data.action_data.name, "post", + "should trigger execute_action with correct method name"); + assert.strictEqual(event.data.record_id, 2, "should have correct id in event data"); + event.data.on_success(); + }); + rpcCount = 0; + form.$('.o_form_statusbar button.p').click(); + + assert.strictEqual(rpcCount, 1, "should have done 1 rpcs to reload"); + + testUtils.intercept(form, 'execute_action', function (event) { + event.data.on_fail(); + }); + form.$('.o_form_statusbar button.s').click(); + + assert.strictEqual(rpcCount, 2, "should have done 1 rpcs to reload"); + form.destroy(); + }); + + QUnit.test('buttons in form view, new record', function (assert) { + // this simulates a situation similar to the settings forms. + assert.expect(6); + + var resID; + + var form = 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); + }, + }); + + testUtils.intercept(form, 'execute_action', function (event) { + assert.step('execute_action'); + assert.strictEqual(event.data.record_id, resID, + "execute action should be done on correct record id"); + event.data.on_success(); + }); + form.$('.o_form_statusbar button.p').click(); + + assert.verifySteps(['default_get', 'create', 'execute_action', 'read']); + form.destroy(); + }); + + QUnit.test('change and save char', function (assert) { + assert.expect(6); + var form = 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.strictEqual(form.$('span:contains(blip)').length, 1, + "should contain span with field value"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode'); + form.$('input').val("tralala").trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); + assert.strictEqual(form.$('span:contains(tralala)').length, 1, + "should contain span with field value"); + form.destroy(); + }); + + QUnit.test('properly reload data from server', function (assert) { + assert.expect(1); + + var form = 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, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('input').val("tralala").trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('span:contains(apple)').length, 1, + "should contain span with field value"); + form.destroy(); + }); + + QUnit.test('properly apply onchange in simple case', function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length + 1000; + }, + }; + var form = 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, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('input').eq(1).val(), "9", + "should contain input with initial value"); + + form.$('input').first().val("tralala").trigger('input'); + + assert.strictEqual(form.$('input').eq(1).val(), "1007", + "should contain input with onchange applied"); + form.destroy(); + }); + + QUnit.test('onchange send only the present fields to the server', 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 = 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.display_name": "", "p.product_id": "", "timmy": "", "timmy.name": ""}, + "should send only the fields used in the views"); + } + return this._super(route, args); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('input:first').val("tralala").trigger('input'); + + form.destroy(); + }); + + QUnit.test('evaluate in python field options', 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 = 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', function (assert) { + assert.expect(4); + + var form = 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, + }); + var n = this.data.partner.records.length; + + form.$buttons.find('.o_form_button_create').click(); + 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"); + form.$buttons.find('.o_form_button_save').click(); + 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('sidebar is hidden when switching to edit mode', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '</form>', + viewOptions: {sidebar: true}, + res_id: 1, + }); + + assert.ok(!form.sidebar.$el.hasClass('o_hidden'), 'sidebar should be visible'); + form.$buttons.find('.o_form_button_edit').click(); + assert.ok(form.sidebar.$el.hasClass('o_hidden'), 'sidebar should be invisible'); + form.$buttons.find('.o_form_button_cancel').click(); + assert.ok(!form.sidebar.$el.hasClass('o_hidden'), 'sidebar should be visible'); + form.destroy(); + }); + + QUnit.test('basic default record', function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.default = "default foo value"; + + var count = 0; + var form = 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').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', 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}], // create a new value + [0, 0, {foo: "new foo2", product_id: 37}], // create a new value + ]; + + var nameGetCount = 0; + + var form = 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.ok(form.$('td:contains(new foo1)').length, + "should have new foo1 value in one2many"); + assert.ok(form.$('td:contains(new foo2)').length, + "should have new foo2 value in one2many"); + assert.ok(form.$('td:contains(xphone)').length, + "should have a cell with the name field 'product_id', set to xphone"); + assert.strictEqual(nameGetCount, 1, "should have done only 1 nameget"); + form.destroy(); + }); + + QUnit.test('make default record with non empty many2one', function (assert) { + var done = assert.async(); + assert.expect(2); + + this.data.partner.fields.trululu.default = 4; + + var nameGetCount = 0; + + createAsyncView({ + 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++; + var result = this._super.apply(this, arguments); + return concurrency.delay(1).then(function () { + return result; + }); + } + return this._super.apply(this, arguments); + }, + }).then(function (form) { + assert.ok(form.$('.o_form_input').val(), 'aaa', + 'default value should be correctly displayed'); + assert.strictEqual(nameGetCount, 1, 'should have done one name_get'); + form.destroy(); + done(); + }); + }); + + QUnit.test('form view properly change its title', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.get('title'), 'first record', + "should have the display name of the record as title"); + form.$buttons.find('.o_form_button_create').click(); + assert.strictEqual(form.get('title'), _t("New"), + "should have the display name of the record as title"); + form.destroy(); + }); + + QUnit.test('can duplicate a record', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + viewOptions: {sidebar: true}, + }); + + assert.strictEqual(form.get('title'), 'first record', + "should have the display name of the record as title"); + form.sidebar.$('a:contains(Duplicate)').click(); + + assert.strictEqual(form.get('title'), 'first record (copy)', + "should have duplicated the record"); + + assert.strictEqual(form.mode, "edit", 'should be in edit mode'); + form.destroy(); + }); + + QUnit.test('buttons in footer are moved to $buttons if required', function (assert) { + // not sure about this test... + assert.expect(2); + + var form = 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: {footer_to_buttons: true}, + }); + + assert.ok(form.$buttons.find('button.infooter').length, "footer button should be in footer"); + assert.ok(!form.$('button.infooter').length, "footer button should not be in form"); + form.destroy(); + }); + + QUnit.test('clicking on stat buttons in edit mode', function (assert) { + assert.expect(8); + + var form = 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); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + var count = 0; + testUtils.intercept(form, "execute_action", function (event) { + event.stopPropagation(); + count++; + }); + form.$('.oe_stat_button').first().click(); + assert.strictEqual(count, 1, "should have triggered a execute action"); + assert.strictEqual(form.mode, "edit", "form view should be in edit mode"); + + + form.$('input').val("tralala").trigger('input'); + form.$('.oe_stat_button').first().click(); + + 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']); + form.destroy(); + }); + + + QUnit.test('buttons with attr "special" do not trigger a save', function (assert) { + assert.expect(4); + + var executeActionCount = 0; + var writeCount = 0; + + var form = 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="Discard" class="btn-default" special="cancel"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + writeCount++; + } + return this._super(route, args); + } + }); + testUtils.intercept(form, "execute_action", function () { + executeActionCount++; + }); + + form.$buttons.find('.o_form_button_edit').click(); + + // make the record dirty + form.$('input').val("tralala").trigger('input'); + + + form.$('button').eq(0).click(); + assert.strictEqual(writeCount, 1, "should have triggered a write"); + assert.strictEqual(executeActionCount, 1, "should have triggered a execute action"); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('input').val("abcdef").trigger('input'); + + form.$('button').eq(1).click(); + assert.strictEqual(writeCount, 1, "should not have triggered a write"); + assert.strictEqual(executeActionCount, 2, "should have triggered a execute action"); + form.destroy(); + }); + + QUnit.test('missing widgets do not crash', function (assert) { + assert.expect(1); + + this.data.partner.fields.foo.type = 'new field type without widget'; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + assert.strictEqual(form.$('.o_field_widget').length, 1, "should have rendered an abstract field"); + form.destroy(); + }); + + QUnit.test('nolabel', function (assert) { + assert.expect(6); + + var form = 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.strictEqual(form.$("label").length, 2, "should have rendered only two label"); + assert.strictEqual(form.$("label").first().text(), "Product", + "one should be the one for the product field"); + assert.strictEqual(form.$("label").eq(1).text(), "Bar", + "one should be the one for the bar field"); + + assert.strictEqual(form.$('.firstgroup td').first().attr('colspan'), "1", + "foo td should have a colspan of 1"); + assert.strictEqual(form.$('.secondgroup tr').length, 2, + "int_field and qux should have same tr"); + + assert.strictEqual(form.$('.secondgroup tr:first td').length, 2, + "product_id field should be on its own tr"); + form.destroy(); + }); + + QUnit.test('many2one in a one2many', function (assert) { + assert.expect(1); + + this.data.partner.records[0].p = [2]; + this.data.partner.records[1].product_id = 37; + + var form = 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.strictEqual(form.$('td:contains(xphone)').length, 1, + "should display the name of the many2one"); + form.destroy(); + }); + + QUnit.test('discard changes on a non dirty form view', function (assert) { + assert.expect(4); + + var nbWrite = 0; + var form = 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 + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_input').val(), 'yop', 'input should contain yop'); + + // click on discard + form.$buttons.find('.o_form_button_cancel').click(); + assert.ok(!$('.modal').length, 'no confirm modal should be displayed'); + assert.strictEqual(form.$('.o_form_field').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', function (assert) { + assert.expect(7); + + var nbWrite = 0; + var form = 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 + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_input').val(), 'yop', 'input should contain yop'); + form.$('.o_form_input').val('new value').trigger('input'); + assert.strictEqual(form.$('.o_form_input').val(), 'new value', 'input should contain new value'); + + // click on discard and cancel the confirm request + form.$buttons.find('.o_form_button_cancel').click(); + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-default').click(); // click on cancel + assert.strictEqual(form.$('.o_form_input').val(), 'new value', 'input should still contain new value'); + + // click on discard and confirm + form.$buttons.find('.o_form_button_cancel').click(); + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-primary').click(); // click on confirm + assert.strictEqual(form.$('.o_form_field').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 new (non dirty, except for defaults) form view', function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.default = "ABC"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + }); + + // switch to edit mode and edit the foo field + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_input').val(), 'ABC', 'input should contain ABC'); + + form.$buttons.find('.o_form_button_cancel').click(); + + assert.strictEqual($('.modal').length, 0, + 'there should not be a confirm modal'); + + form.destroy(); + }); + + QUnit.test('discard changes on a duplicated record', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + res_id: 1, + viewOptions: {sidebar: true}, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('input').val("tralala").trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + + form.sidebar.$('a:contains(Duplicate)').click(); + + assert.strictEqual(form.$('.o_form_input').val(), 'tralala', 'input should contain ABC'); + + form.$buttons.find('.o_form_button_cancel').click(); + + assert.strictEqual($('.modal').length, 0, + 'there should not be a confirm modal'); + + form.destroy(); + }); + + QUnit.test('switching to another record from a dirty one', function (assert) { + assert.expect(11); + + var nbWrite = 0; + var form = 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(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + assert.strictEqual(form.pager.$('.o_pager_limit').text(), "2", 'pager limit should be 2'); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_input').val(), 'yop', 'input should contain yop'); + + // edit the foo field + form.$('.o_form_input').val('new value').trigger('input'); + assert.strictEqual(form.$('.o_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 + form.pager.$('.o_pager_next').click(); // click on next + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-default').click(); // click on cancel + assert.strictEqual(form.$('.o_form_input').val(), 'new value', 'input should still contain new value'); + assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should still be 1'); + + // click on the pager to switch to the next record and confirm + form.pager.$('.o_pager_next').click(); // click on next + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-primary').click(); // click on confirm + assert.strictEqual(form.$('.o_form_input').val(), 'blip', 'input should contain blip'); + assert.strictEqual(form.pager.$('.o_pager_value').text(), "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', function (assert) { + assert.expect(12); + + var form = 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(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_input').val(), 'yop', 'input should contain yop'); + + // edit the foo field + form.$('.o_form_input').val('new value').trigger('input'); + assert.strictEqual(form.$('.o_form_input').val(), 'new value', 'input should contain new value'); + + form.$buttons.find('.o_form_button_save').click(); + + // click on the pager to switch to the next record and cancel the confirm request + form.pager.$('.o_pager_next').click(); // click on next + assert.strictEqual($('.modal').length, 0, 'no confirm modal should be displayed'); + assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2'); + + assert.strictEqual(form.$('.o_priority .fa-star-o').length, 2, + 'priority widget should have been rendered with correct value'); + + // edit the value in readonly + form.$('.o_priority .fa-star-o:first').click(); // click on the first star + assert.strictEqual(form.$('.o_priority .fa-star').length, 1, + 'priority widget should have been updated'); + + form.pager.$('.o_pager_next').click(); // click on next + assert.strictEqual($('.modal').length, 0, 'no confirm modal should be displayed'); + assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_input').val(), 'new value', 'input should contain yop'); + + // edit the foo field + form.$('.o_form_input').val('wrong value').trigger('input'); + + form.$buttons.find('.o_form_button_cancel').click(); + assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-primary').click(); // click on confirm + form.pager.$('.o_pager_next').click(); // click on next + assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2'); + form.destroy(); + }); + + QUnit.test('handling dirty state: canBeDiscarded should be idempotent', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="foo"></field>' + + '</form>', + res_id: 1, + }); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_form_input').val(), 'yop', 'input should contain yop'); + + // edit the foo field to make it dirty + form.$('.o_form_input').val('new value').trigger('input'); + + // discard changes once + form.canBeDiscarded(); + assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-primary').click(); // click on confirm + + // discard changes a second time + form.canBeDiscarded(); + assert.strictEqual($('.modal').length, 0, 'no confirm modal should be displayed'); + + form.destroy(); + }); + + + QUnit.test('restore local state when switching to another record', function (assert) { + assert.expect(4); + + var form = 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 + form.$('.o_notebook li:eq(1) a').click(); + + assert.notOk(form.$('.o_notebook li:eq(0)').hasClass('active'), + "first tab should not be active"); + assert.ok(form.$('.o_notebook li:eq(1)').hasClass('active'), + "second tab should be active"); + + // click on the pager to switch to the next record + form.pager.$('.o_pager_next').click(); + + assert.notOk(form.$('.o_notebook li:eq(0)').hasClass('active'), + "first tab should not be active"); + assert.ok(form.$('.o_notebook li:eq(1)').hasClass('active'), + "second tab should be active"); + form.destroy(); + }); + + QUnit.test('pager is hidden in create mode', function (assert) { + assert.expect(7); + + var form = 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.ok(form.pager.$el.is(':visible'), "pager should be visible"); + assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", + "current pager value should be 1"); + assert.strictEqual(form.pager.$('.o_pager_limit').text(), "2", + "current pager limit should be 1"); + form.$buttons.find('.o_form_button_create').click(); + + assert.notOk(form.pager.$el.is(':visible'), "pager should not be visible"); + form.$buttons.find('.o_form_button_save').click(); + + assert.ok(form.pager.$el.is(':visible'), "pager should be visible"); + assert.strictEqual(form.pager.$('.o_pager_value').text(), "3", + "current pager value should be 3"); + assert.strictEqual(form.pager.$('.o_pager_limit').text(), "3", + "current pager limit should be 3"); + form.destroy(); + }); + + QUnit.test('switching to another record, in readonly mode', function (assert) { + assert.expect(5); + + var pushStateCount = 0; + + var form = 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(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + form.pager.$('.o_pager_next').click(); // click on next + + assert.strictEqual(form.pager.$('.o_pager_value').text(), "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', function (assert) { + assert.expect(4); + + var form = 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.strictEqual(form.$('span.foo_field').length, 1, "should have a span foo field"); + + assert.ok(!form.$('span.foo_field').is(':visible'), + "foo field should not be visible"); + + form.$buttons.find('.o_form_button_create').click(); + + assert.strictEqual(form.$('input.foo_field').length, 1, + "should have a visible input for foo field"); + + assert.ok(form.$('input.foo_field').is(':visible'), + "foo field should be visible"); + form.destroy(); + }); + + QUnit.test('empty readonly fields are visible on new records', 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 = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet><group>' + + '<field name="foo"/>' + + '</group></sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_form_field_empty').length, 1, + 'readonly field should be invisible on an existing record'); + + form.$buttons.find('.o_form_button_create').click(); + + assert.strictEqual(form.$('.o_form_field_empty').length, 0, + 'readonly field should be visible on a new record'); + form.destroy(); + }); + + + QUnit.test('all group children have correct layout classname', function (assert) { + assert.expect(2); + + var form = 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.ok(form.$('.inner_group').hasClass('o_group_col_6'), + "inner groups should have classname 'o_group_col_6'"); + assert.ok(form.$('.inner_div').hasClass('o_group_col_6'), + "divs inside groups should have classname 'o_group_col_6'"); + form.destroy(); + }); + + QUnit.test('deleting a record', function (assert) { + assert.expect(8); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="foo"></field></form>', + viewOptions: { + ids: [1, 2, 4], + index: 0, + sidebar: true, + }, + res_id: 1, + }); + + assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + assert.strictEqual(form.pager.$('.o_pager_limit').text(), "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').length, 'no confirm modal should be displayed'); + + // open sidebar + form.sidebar.$('button.o_dropdown_toggler_btn').click(); + form.sidebar.$('a:contains(Delete)').click(); + + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + + // confirm the delete + $('.modal .modal-footer button.btn-primary').click(); + + assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + assert.strictEqual(form.pager.$('.o_pager_limit').text(), "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('empty required fields cannot be saved', function (assert) { + assert.expect(5); + + this.data.partner.fields.foo.required = true; + delete this.data.partner.fields.foo.default; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<group><field name="foo"/></group>' + + '</form>', + }); + + testUtils.intercept(form, 'warning', function (event) { + assert.strictEqual(event.data.title, 'The following fields are invalid:', + "should have a warning with correct title"); + assert.strictEqual(event.data.message, '<ul><li>Foo</li></ul>', + "should have a warning with correct message"); + }); + + form.$buttons.find('.o_form_button_save').click(); + assert.ok(form.$('label').hasClass('o_form_invalid'), + "label should be tagged as invalid"); + assert.ok(form.$('input').hasClass('o_form_invalid'), + "input should be tagged as invalid"); + + form.$('input').val("tralala").trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('.o_form_invalid').length, 0, + "nothing should be marked as invalid"); + form.destroy(); + }); + + QUnit.test('changes in a readonly form view are saved directly', function (assert) { + assert.expect(10); + + var nbWrite = 0; + var form = 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.strictEqual(form.$('.o_priority .o_priority_star').length, 2, + 'priority widget should have been rendered'); + assert.strictEqual(form.$('.o_priority .fa-star-o').length, 2, + 'priority widget should have been rendered with correct value'); + + // edit the value in readonly + form.$('.o_priority .fa-star-o:first').click(); // click on the first star + assert.strictEqual(nbWrite, 1, 'should have saved directly'); + assert.strictEqual(form.$('.o_priority .fa-star').length, 1, + 'priority widget should have been updated'); + + // switch to edit mode and edit the value again + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_priority .o_priority_star').length, 2, + 'priority widget should have been correctly rendered'); + assert.strictEqual(form.$('.o_priority .fa-star').length, 1, + 'priority widget should have correct value'); + form.$('.o_priority .fa-star-o:first').click(); // click on the second star + assert.strictEqual(nbWrite, 1, 'should not have saved directly'); + assert.strictEqual(form.$('.o_priority .fa-star').length, 2, + 'priority widget should have been updated'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(nbWrite, 2, 'should not have saved directly'); + assert.strictEqual(form.$('.o_priority .fa-star').length, 2, + 'priority widget should have correct value'); + form.destroy(); + }); + + QUnit.test('display a dialog if onchange result is a warning', function (assert) { + assert.expect(5); + + this.data.partner.onchanges = { foo: true }; + + var form = 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 $.when({ + 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.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"); + }, + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_form_input[name=int_field]').val(), '9', + "'int_field' value should be 9 before the change"); + + form.$('input').first().val("tralala").trigger('input'); + + assert.strictEqual(form.$('.o_form_input[name=int_field]').val(), '10', + "the onchange should have been correctly applied"); + + form.destroy(); + }); + + QUnit.test('do nothing if add a line in one2many result in a onchange with a warning', function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { foo: true }; + + var form = 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 $.when({ + 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 + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_field_x2many_list_row_add a').click(); + assert.strictEqual(form.$('tr.o_data_row').length, 0, + "should not have added a line"); + form.destroy(); + }); + + QUnit.test('attrs are properly transmitted to new records', function (assert) { + assert.expect(2); + + // this test checks that the fieldsInfo have been transmitted to the + // load function when creating a new record + + var terminology = { + string_true: "Production Environment", + hover_true: "Switch to test environment", + string_false: "Test Environment", + hover_false: "Switch to production environment" + }; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="bar" widget="boolean_button" options=\'{"terminology": ' + + JSON.stringify(terminology) + '}\'/>' + + '</group>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_stat_text.o_not_hover:contains(Production Environment)').length, 1, + "button should contain correct string"); + + form.$buttons.find('.o_form_button_create').click(); + + assert.strictEqual(form.$('.o_stat_text.o_not_hover:contains(Test Environment)').length, 1, + "button should contain correct string"); + + form.destroy(); + }); + + QUnit.test('button box is not rendered in create mode', function (assert) { + assert.expect(3); + + var form = 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.strictEqual(form.$('.oe_stat_button').length, 1, + "button box should be displayed in readonly"); + + // edit mode + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.oe_stat_button').length, 1, + "button box should be displayed in edit on an existing record"); + + // create mode + form.$buttons.find('.o_form_button_create').click(); + assert.strictEqual(form.$('.oe_stat_button').length, 0, + "button box should not be displayed when creating a new record"); + + form.destroy(); + }); + + QUnit.test('properly apply onchange on many2many fields', function (assert) { + assert.expect(3); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.timmy = [ + [5], + [1, 12, {display_name: "gold"}], + ]; + }, + }; + var form = 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>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_form_field_many2many .o_data_row').length, 0, + "there should be no many2many record linked at first"); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_input').val('let us trigger an onchange').trigger('input'); + assert.strictEqual(form.$('.o_form_field_many2many .o_data_row').length, 1, + "there should be one linked record"); + assert.strictEqual(form.$('.o_form_field_many2many .o_data_row td:first').text(), 'gold', + "the 'display_name' of the many2many record should be correctly displayed"); + + form.destroy(); + }); + + QUnit.test('onchanges on date(time) fields', 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 = 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, + }); + + assert.strictEqual(form.$('.o_form_field[name=date]').text(), + '01/25/2017', "the initial date should be correct"); + assert.strictEqual(form.$('.o_form_field[name=datetime]').text(), + '12/12/2016 10:55:05', "the initial datetime should be correct"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_form_field[name=date] input').val(), + '01/25/2017', "the initial date should be correct in edit"); + assert.strictEqual(form.$('.o_form_field[name=datetime] input').val(), + '12/12/2016 10:55:05', "the initial datetime should be correct in edit"); + + // trigger the onchange + form.$('.o_form_field[name="foo"]').val("coucou").trigger('input'); + + assert.strictEqual(form.$('.o_form_field[name=date] input').val(), + '12/12/2021', "the initial date should be correct in edit"); + assert.strictEqual(form.$('.o_form_field[name=datetime] input').val(), + '12/12/2021 10:55:05', "the initial datetime should be correct in edit"); + + form.destroy(); + }); + + QUnit.test('onchanges are not sent for each keystrokes', 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 = $.Deferred(); + var form = 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; + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + form.$('input').first().val("1").trigger('input'); + assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet"); + form.$('input').first().val("12").trigger('input'); + assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet"); + + return waitForFinishedOnChange().then(function () { + assert.strictEqual(onchangeNbr, 1, "one onchange has been called"); + + // add something in the input, then focus another input + form.$('input').first().val("123").trigger('input'); + form.$('input').first().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 = $.Deferred(); + return concurrency.delay(0); + }); + } + }); + + QUnit.test('rpc complete after destroying parent', function (assert) { + // We just test that there is no crash in this situation + assert.expect(0); + var form = 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(); + } + } + }); + form.$('.o_form_button_update').click(); + }); + + QUnit.test('onchanges that complete after discarding', function (assert) { + assert.expect(4); + + var def1 = $.Deferred(); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length + 1000; + }, + }; + var form = 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"); + form.$buttons.find('.o_form_button_edit').click(); + + // edit a value + form.$('input').first().val("1234").trigger('input'); + + // discard changes + form.$buttons.find('.o_form_button_cancel').click(); + $('.modal .modal-footer .btn-primary').click(); + assert.strictEqual(form.$('span[name="foo"]').text(), "blip", + "field foo should still be displayed to initial value"); + + // complete the onchange + def1.resolve(); + assert.strictEqual(form.$('span[name="foo"]').text(), "blip", + "field foo should still be displayed to initial value"); + + form.destroy(); + }); + + QUnit.test('unchanged relational data is sent for onchanges', 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 = 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); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_input:first').val('trigger an onchange').trigger('input'); + + form.destroy(); + }); + + QUnit.test('onchanges on unknown fields of o2m are ignored', 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 = 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 $.when({ + 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); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_input:first').val('trigger an onchange').trigger('input'); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed', + "onchange should have been correctly applied on field in o2m list"); + + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + }); + + QUnit.test('onchange value are not discarded on o2m edition', function (assert) { + assert.expect(3); + + this.data.partner.records[1].p = [4]; + this.data.partner.onchanges = { + foo: function () {}, + }; + var form = 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 $.when({ + 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); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value', + "the initial value should be the default one"); + + form.$('.o_form_input:first').val('trigger an onchange').trigger('input'); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed', + "onchange should have been correctly applied on field in o2m list"); + + form.$('.o_data_row').click(); // edit the o2m in the dialog + assert.strictEqual($('.modal .o_form_field').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)', 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 = 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, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value', + "the initial value should be the default one"); + + form.$('.o_data_row td:nth(1)').click(); // edit the o2m inline + form.$('.o_data_row input:nth(1)').val(77).trigger('input'); + + assert.strictEqual(form.$('.o_data_row input:first').val(), '[blip] 77', + "onchange should have been correctly applied"); + + // create a new o2m record + form.$('.o_form_field_x2many_list_row_add a').click(); + 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)', function (assert) { + assert.expect(5); + + 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 = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree>' + + '<field name="foo"/>' + + '</tree>' + + '<form>' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</form>' + + '</field>' + + '</group>' + + '</form>', + res_id: 2, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value', + "the initial value should be the default one"); + + form.$('.o_data_row td:first').click(); // edit the o2m in a dialog + $('.modal .o_form_input:nth(1)').val(77).trigger('input'); + assert.strictEqual($('.modal .o_form_input:first').val(), '[blip] 77', + "onchange should have been correctly applied"); + $('.modal .modal-footer .btn-primary').click(); // save the dialog + assert.strictEqual(form.$('.o_data_row td:first').text(), '[blip] 77', + "onchange should have been correctly applied"); + + // create a new o2m record + form.$('.o_form_field_x2many_list_row_add a').click(); + assert.strictEqual($('.modal .o_form_input:first').val(), '[blip] 14', + "onchange should have been correctly applied after default get"); + $('.modal .modal-footer .btn-primary').click(); // save the dialog + 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', function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { + foo: function () {}, + }; + var form = 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.args[4].test, 1, + "the context of the field triggering the onchange should be given"); + assert.strictEqual(args.args[4].int_ctx, undefined, + "the context of other fields should not be given"); + } + return this._super.apply(this, arguments); + }, + res_id: 2, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_form_input:first').val('coucou').trigger('input'); + + form.destroy(); + }); + + QUnit.test('navigation with tab key in form view', function (assert) { + assert.expect(2); + + var form = 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: 2, + }); + + // go to edit mode + form.$buttons.find('.o_form_button_edit').click(); + + // focus first input, trigger tab + form.$('input[name="foo"]').focus(); + form.$('input[name="foo"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.ok($.contains(form.$('div[name="bar"]')[0], document.activeElement), + "bar checkbox should be focused"); + + // simulate shift+tab on active element + $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true})); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "first input should be focused"); + + form.destroy(); + }); + + QUnit.test('clicking on a stat button with a context', function (assert) { + assert.expect(1); + + var form = 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, + 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"); + }, + }, + }); + + form.$('.oe_stat_button').click(); + + form.destroy(); + }); + + QUnit.test('diplay a stat button outside a buttonbox', function (assert) { + assert.expect(3); + + var form = 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.strictEqual(form.$('button .o_field_widget').length, 1, + "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 than the field value"); + form.destroy(); + }); + + QUnit.test('one2many default value creation', function (assert) { + assert.expect(1); + + this.data.partner.records[0].product_ids = [37]; + + var form = 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 === 'default_get') { + return $.when({ + product_ids: [[0, 0, { + name: 'xdroid', + partner_type_id: 12, + }]] + }); + } + 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); + }, + }); + form.$buttons.find('.o_form_button_save').click(); + 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 00000000000..faab8420fbb --- /dev/null +++ b/addons/web/static/tests/views/graph_tests.js @@ -0,0 +1,256 @@ +odoo.define('web.graph_tests', function (require) { +"use strict"; + +var concurrency = require('web.concurrency'); +var GraphView = require('web.GraphView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +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'}, + color_id: {string: "Color", type: "many2one", relation: 'color'}, + }, + records: [ + {id: 1, foo: 3, bar: true, product_id: 37}, + {id: 2, foo: 53, bar: true, product_id: 37, color_id: 7}, + {id: 3, foo: 2, bar: true, product_id: 37}, + {id: 4, foo: 24, bar: false, product_id: 37}, + {id: 5, foo: 4, bar: false, product_id: 41}, + {id: 6, foo: 63, bar: false, product_id: 41}, + {id: 7, foo: 42, bar: false, product_id: 41}, + ] + }, + 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', function (assert) { + assert.expect(4); + + var graph = createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="bar"/>' + + '</graph>', + }); + + var done = assert.async(); + return concurrency.delay(0).then(function () { + assert.strictEqual(graph.$('div.o_graph_svg_container svg.nvd3-svg').length, 1, + "should contain a div with a svg element"); + + assert.strictEqual(graph.renderer.state.mode, "bar", + "should be in bar chart mode by default"); + + // here, we would like to test the svg in the dom. However, it is + // not so easy, because there is an animation which means that we + // don't really have a nice way to find the proper rect elements. + // So, instead we will do some white box testing. + assert.strictEqual(graph.model.chart.data[0].value, 3, + "should have first datapoint with value 3"); + assert.strictEqual(graph.model.chart.data[1].value, 4, + "should have first datapoint with value 4"); + graph.destroy(); + done(); + }); + }); + + + QUnit.test('default type attribute', function (assert) { + assert.expect(1); + + var graph = 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('switching mode', function (assert) { + assert.expect(6); + + var graph = 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.notOk(graph.$buttons.find('button[data-mode="bar"]').hasClass('active'), + 'bar type button should not be active'); + assert.ok(graph.$buttons.find('button[data-mode="line"]').hasClass('active'), + 'line type button should be active'); + graph.$buttons.find('button[data-mode="bar"]').click(); + assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode by default"); + assert.notOk(graph.$buttons.find('button[data-mode="line"]').hasClass('active'), + 'line type button should not be active'); + assert.ok(graph.$buttons.find('button[data-mode="bar"]').hasClass('active'), + 'bar type button should be active'); + graph.destroy(); + }); + + QUnit.test('displaying line chart with only 1 data point', function (assert) { + assert.expect(2); + + // this test makes sure the line chart does not crash when only one data + // point is displayed. This was the case since a line cannot be drawn with + // only one point of reference. + var done = assert.async(); + this.data.foo.records = this.data.foo.records.slice(0,1); + var graph = createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="line">' + + '<field name="bar"/>' + + '</graph>', + }); + return concurrency.delay(0).then(function () { + assert.ok(!graph.$('svg').length, "should not have a svg"); + assert.ok(graph.$('.oe_view_nocontent').length, "should have an error message"); + graph.destroy(); + done(); + }); + }); + + QUnit.test('switching measures', function (assert) { + var done = assert.async(); + assert.expect(4); + + var rpcCount = 0; + + var graph = 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); + }, + }); + return concurrency.delay(0).then(function () { + assert.ok(graph.$('text.nv-legend-text:contains(Count)').length, + "should have used the correct measure"); + assert.ok(graph.$buttons.find('li[data-field="foo"]').length, + "should have foo in the list of measures"); + graph.$buttons.find('li[data-field="foo"] a').click(); + + return concurrency.delay(0); + }).then(function () { + assert.ok(graph.$('text.nv-legend-text:contains(Foo)').length, + "should now use the Foo measure"); + assert.strictEqual(rpcCount, 2, "should have done 2 rpcs (2 readgroups)"); + graph.destroy(); + done(); + }); + }); + + + QUnit.test('no content helper', function (assert) { + var done = assert.async(); + assert.expect(4); + + var graph = createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Gloups">' + + '<field name="product_id"/>' + + '</graph>', + }); + return concurrency.delay(0).then(function () { + assert.ok(graph.$('div.o_graph_svg_container svg.nvd3-svg').length, + "should contain a div with a svg element"); + assert.notOk(graph.$('div.oe_view_nocontent').length, + "should not display the no content helper"); + graph.update({domain: [['product_id', '=', 4]]}); + + assert.notOk(graph.$('div.o_graph_svg_container svg.nvd3-svg').length, + "should not contain a div with a svg element"); + assert.ok(graph.$('div.oe_view_nocontent').length, + "should display the no content helper"); + graph.destroy(); + done(); + }); + }); + + QUnit.test('can reload with other group by', function (assert) { + var done = assert.async(); + assert.expect(4); + + var graph = createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Gloups">' + + '<field name="product_id"/>' + + '</graph>', + }); + return concurrency.delay(0).then(function () { + assert.ok(graph.$('text:contains(xphone)').length, + "should contain a text element with product in legend"); + assert.notOk(graph.$('text:contains(red)').length, + "should not contain a text element with color in legend"); + + graph.update({groupBy: ['color_id']}); + + return concurrency.delay(0); + }).then(function () { + assert.notOk(graph.$('text:contains(xphone)').length, + "should not contain a text element with product in legend"); + assert.ok(graph.$('text:contains(red)').length, + "should contain a text element with color in legend"); + graph.destroy(); + done(); + }); + }); + +}); + +}); 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 00000000000..7c64b7dcf6a --- /dev/null +++ b/addons/web/static/tests/views/kanban_model_tests.js @@ -0,0 +1,253 @@ +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, + }; + } +}, function () { + + QUnit.module('KanbanModel'); + + QUnit.test('load grouped + add a new group', function (assert) { + assert.expect(20); + + var calledRoutes = {}; + var model = 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(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.count, 2, 'should have found 2 groups'); + 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 + model.createGroup('xpod', resultID); + state = model.get(resultID); + assert.strictEqual(state.count, 3, 'should now have 3 groups'); + 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/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(); + }); + }); + + QUnit.test('archive/restore a column', function (assert) { + assert.expect(4); + + var model = createModel({ + Model: KanbanModel, + data: this.data, + }); + + var params = _.extend(this.params, { + groupedBy: ['product_id'], + fieldNames: ['foo'], + }); + + model.load(params).then(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 = _.pluck(xphoneGroup.data, 'id'); + model.toggleActive(recordIDs, false, 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(); + }); + }); + + QUnit.test('kanban model does not allow nested groups', function (assert) { + assert.expect(2); + + var model = createModel({ + Model: KanbanModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === '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(); + }); + }); + + QUnit.test('resequence columns and records', function (assert) { + var done = assert.async(); + assert.expect(8); + + this.data.partner.records.push({id: 3, foo: 'aaa', product_id: 37}); + + var nbReseq = 0; + var model = 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_id', + "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 $.when(); + } + 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_id', [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(); + }); + + }); +}); + +}); 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 00000000000..dd9b2eb024a --- /dev/null +++ b/addons/web/static/tests/views/kanban_tests.js @@ -0,0 +1,1068 @@ +odoo.define('web.kanban_tests', function (require) { +"use strict"; + +var KanbanView = require('web.KanbanView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +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", 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'}, + }, + records: [ + {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: []}, + {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]}, + {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]}, + {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []}, + ] + }, + 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: "int"}, + }, + records: [ + {id: 6, name: "gold", color: 2}, + {id: 7, name: "silver", color: 5}, + ] + }, + }; + } +}, function () { + + QUnit.module('KanbanView'); + + QUnit.test('basic ungrouped rendering', function (assert) { + assert.expect(5); + + var kanban = 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>', + }); + + assert.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_ungrouped'), + "should have classname 'o_kanban_ungrouped'"); + assert.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_test'), + "should have classname 'o_kanban_test'"); + + assert.strictEqual(kanban.$('.o_kanban_record:not(.o_kanban_ghost)').length, 4, + "should have 4 records"); + assert.strictEqual(kanban.$('.o_kanban_ghost').length, 6, "should have 6 ghosts"); + assert.strictEqual(kanban.$('.o_kanban_record:contains(gnap)').length, 1, + "should contain gnap"); + kanban.destroy(); + }); + + QUnit.test('basic grouped rendering', function (assert) { + assert.expect(13); + + var kanban = 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 === '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.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_grouped'), + "should have classname 'o_kanban_grouped'"); + assert.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_test'), + "should have classname 'o_kanban_test'"); + 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, 1, + "column should contain " + 1 + " record(s)"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 3, + "column should contain " + 3 + " record(s)"); + // check available actions in kanban header's config dropdown + assert.ok(kanban.$('.o_kanban_header:first .o_kanban_config .o_kanban_toggle_fold').length, + "should be able to fold the column"); + assert.ok(!kanban.$('.o_kanban_header:first .o_kanban_config .o_column_archive').length, + "should be not able to archive the records"); + assert.ok(!kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive').length, + "should be not able to restore the records"); + assert.ok(!kanban.$('.o_kanban_header:first .o_kanban_config .o_column_edit').length, + "should not be able to edit the column"); + assert.ok(!kanban.$('.o_kanban_header:first .o_kanban_config .o_column_delete').length, + "should not be able to delete the column"); + + // 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. + kanban.reload(); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 3, + "column should contain " + 3 + " record(s)"); + kanban.destroy(); + }); + + QUnit.test('basic grouped rendering with active field', 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 = 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'], + }); + + // check available actions in kanban header's config dropdown + assert.ok(kanban.$('.o_kanban_header:first .o_kanban_config .o_column_archive').length, + "should be able to archive the records"); + assert.ok(kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive').length, + "should be able to restore the records"); + + // archive the records of the first column + assert.strictEqual(kanban.$('.o_kanban_group:last .o_kanban_record').length, 3, + "last column should contain 3 records"); + kanban.$('.o_kanban_group:last .o_column_archive').click(); // click on 'Archive' + assert.strictEqual(kanban.$('.o_kanban_group:last .o_kanban_record').length, 0, + "last column should contain no record"); + kanban.destroy(); + }); + + QUnit.test('pager should be hidden in grouped mode', function (assert) { + assert.expect(1); + + var kanban = 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'], + }); + kanban.renderPager(); + + assert.ok(kanban.pager.$el.hasClass('o_hidden'), + "pager should be hidden in grouped kanban"); + kanban.destroy(); + }); + + QUnit.test('pager, ungrouped, with default limit', function (assert) { + assert.expect(3); + + var kanban = 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.ok(!kanban.pager.$el.hasClass('o_hidden'), + "pager should be visible in ungrouped kanban"); + assert.strictEqual(kanban.pager.state.size, 4, "pager's size should be 4"); + kanban.destroy(); + }); + + QUnit.test('pager, ungrouped, with limit given in options', function (assert) { + assert.expect(3); + + var kanban = 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(kanban.pager.state.limit, 2, "pager's limit should be 2"); + assert.strictEqual(kanban.pager.state.size, 4, "pager's size should be 4"); + kanban.destroy(); + }); + + QUnit.test('pager, ungrouped, with limit set on arch and given in options', function (assert) { + assert.expect(3); + + // the limit given in the arch should take the priority over the one given in options + var kanban = 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(kanban.pager.state.limit, 3, "pager's limit should be 3"); + assert.strictEqual(kanban.pager.state.size, 4, "pager's size should be 4"); + kanban.destroy(); + }); + + QUnit.test('create in grouped on m2o', function (assert) { + assert.expect(5); + + var kanban = 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'], + }); + kanban.renderButtons(); + + assert.ok(kanban.$('.o_kanban_view').hasClass('ui-sortable'), + "columns are sortable when grouped by a m2o field"); + assert.ok(kanban.$buttons.find('.o-kanban-button-new').hasClass('btn-primary'), + "'create' button should be btn-primary for grouped kanban with at least one column"); + assert.ok(kanban.$('.o_kanban_view > div:last').hasClass('o_column_quick_create'), + "column quick create should be enabled when grouped by a many2one field)"); + + kanban.$buttons.find('.o-kanban-button-new').click(); // Click on 'Create' + assert.ok(kanban.$('.o_kanban_group:first() > div:nth(1)').hasClass('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', function (assert) { + assert.expect(4); + + var kanban = 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.ok(!kanban.$('.o_kanban_view').hasClass('ui-sortable'), + "columns aren't sortable when not grouped by a m2o field"); + assert.strictEqual(kanban.$('.o_kanban_group').length, 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.ok(!kanban.$('.o_kanban_view > div:last').hasClass('o_column_quick_create'), + "column quick create should be disabled when not grouped by a many2one field)"); + kanban.destroy(); + }); + + QUnit.test('quick create in grouped mode', function (assert) { + assert.expect(4); + + var kanban = 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'], + }); + + // click to add an element and cancel the quick creation + kanban.$('.o_kanban_header .o_kanban_quick_add i').first().click(); + + 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})); + assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 0, + "should have destroyed the quick create element"); + + // click to really add an element + kanban.$('.o_kanban_header .o_kanban_quick_add i').first().click(); + $quickCreate = kanban.$('.o_kanban_quick_create'); + $quickCreate.find('input').val('new partner'); + $quickCreate.find('button.o_kanban_add').click(); + + 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"); + + kanban.destroy(); + }); + + QUnit.test('quick create fail in grouped', function (assert) { + assert.expect(7); + + var kanban = 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') { + throw new Error("Odoo server error"); + } + return this._super.apply(this, arguments); + }, + }); + kanban.renderButtons(); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "there should be 2 records in first column"); + + kanban.$buttons.find('.o-kanban-button-new').click(); // Click on 'Create' + assert.ok(kanban.$('.o_kanban_group:first() > div:nth(1)').hasClass('o_kanban_quick_create'), + "clicking on create should open the quick_create in the first column"); + + kanban.$('.o_kanban_quick_create input') + .val('test') + .trigger($.Event('keypress', {keyCode: $.ui.keyCode.ENTER})); + + 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_form_field_many2one input').val(), 'hello', + "the correct product_id should already be set"); + + // specify a name and save + $('.modal .o_form_input[name=foo]').val('test').trigger('input'); + $('.modal-footer .btn-primary').click(); + + assert.strictEqual($('.modal').length, 0, "the modal should be closed"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 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"); + + kanban.destroy(); + }); + + QUnit.test('many2manytags are correctly fetched and displayed', function (assert) { + assert.expect(3); + + this.data.partner.records[0].category_ids = [6, 7]; + this.data.partner.records[1].category_ids = [7]; + + var count = 0; + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="category_ids"/>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + mockRPC: function () { + count++; + return this._super.apply(this, arguments); + }, + }); + + var $first_record = kanban.$('.o_kanban_record:first()'); + assert.strictEqual($first_record.find('.o_form_field_many2manytags .o_tag').length, 2, + 'first record should contain 2 tags'); + assert.ok($first_record.find('.o_tag:first()').hasClass('o_tag_color_2'), + 'first tag should have color 2'); + assert.strictEqual(count, 2, + 'two RPC should have been done(one search read and one read for the m2m'); + kanban.destroy(); + }); + + QUnit.test('can drag and drop a record from one column to the next', function (assert) { + assert.expect(7); + + this.data.partner.fields.sequence = {type: 'number', string: "Sequence"}; + var kanban = 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"/>' + + '<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 $.when(true); + } + return this._super(route, args); + }, + }); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2, + "column should contain 2 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.$('.thisiseditable').length, 4, "all records should be editable"); + var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); + var $group = kanban.$('.o_kanban_group:nth-child(2)'); + testUtils.dragAndDrop($record, $group); + + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 1, + "column should now contain 1 record(s)"); + assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length, 3, + "column should contain 3 record(s)"); + assert.strictEqual(kanban.$('.thisiseditable').length, 4, "all records should be editable"); + kanban.destroy(); + }); + + QUnit.test('kanban view with default_group_by', 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 = 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/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.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_grouped'), + "should have classname 'o_kanban_grouped'"); + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have " + 2 + " columns"); + + // simulate an update coming from the searchview, with another groupby given + kanban.update({groupBy: ['product_id']}); + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should now have " + 3 + " columns"); + + // simulate an update coming from the searchview, removing the previously set groupby + kanban.update({groupBy: []}); + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have " + 2 + " columns again"); + kanban.destroy(); + }); + + QUnit.test('kanban view with create=False', function (assert) { + assert.expect(1); + + var kanban = 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', function (assert) { + assert.expect(1); + + var kanban = 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.intercept(kanban, 'switch_view', function (event) { + assert.deepEqual(event.data, { + view_type: 'form', + res_id: 1, + mode: 'edit', + model: 'partner', + }); + }); + kanban.$('a').first().click(); + kanban.destroy(); + }); + + QUnit.test('create a column in grouped on m2o', function (assert) { + assert.expect(10); + + var nbRPCs = 0; + var kanban = 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"); + } + return this._super(route, args); + } + }); + assert.strictEqual(kanban.$('.o_column_quick_create').length, 1, "should have a quick create column"); + assert.notOk(kanban.$('.o_column_quick_create input').is(':visible'), + "the input should not be visible"); + + kanban.$('.o_column_quick_create').click(); + + assert.ok(kanban.$('.o_column_quick_create input').is(':visible'), + "the input should be visible"); + + kanban.$('.o_column_quick_create input').val('new value'); + kanban.$('.o_column_quick_create button.o_kanban_add').click(); + + 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.ok(!kanban.$('.o_kanban_group:last').hasClass('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; + kanban.$('.o_kanban_group:last .o_kanban_toggle_fold').click(); // fold the group + assert.ok(kanban.$('.o_kanban_group:last').hasClass('o_column_folded'), + 'the created column should now be folded'); + kanban.$('.o_kanban_group:last').click(); + assert.ok(!kanban.$('.o_kanban_group:last').hasClass('o_column_folded'), + 'the created column should not be folded'); + assert.strictEqual(nbRPCs, 0, 'no rpc should have been done when folding/unfolding'); + kanban.destroy(); + }); + + QUnit.test('delete a column in grouped on m2o', function (assert) { + assert.expect(21); + + var nbRPCs = 0; + var kanban = 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++; + return this._super(route, args); + } + }); + + // check the initial rendering + assert.strictEqual(kanban.$('.o_kanban_group').length, 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.strictEqual(kanban.$('.o_kanban_group:last .o_kanban_record').length, 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_header:first .o_kanban_config .o_column_archive').length, + "should not be able to archive the records"); + assert.ok(!kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive').length, + "should not be able to restore the records"); + + // delete second column (first cancel the confirm request, then confirm) + kanban.$('.o_kanban_group:last .o_column_delete').click(); // click on delete + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-default').click(); // click on cancel + assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 5, + 'column [5, "xmo"] should still be there'); + kanban.$('.o_kanban_group:last .o_column_delete').click(); // click on delete + assert.ok($('.modal').length, 'a confirm modal should be displayed'); + $('.modal .modal-footer .btn-primary').click(); // click on confirm + assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 3, + 'last column should now be [3, "hello"]'); + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should still have two columns"); + assert.ok(!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_header:first .o_kanban_config .o_column_archive').length, + "should not be able to archive the records"); + assert.ok(!kanban.$('.o_kanban_header:first .o_kanban_config .o_column_unarchive').length, + "should not be able to restore the records"); + kanban.destroy(); + }); + + QUnit.test('edit a column in grouped on m2o', function (assert) { + assert.expect(12); + + var nbRPCs = 0; + var kanban = 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 + kanban.$('.o_kanban_group[data-id=5] .o_column_edit').click(); // click on 'Edit' + assert.ok($('.modal .o_form_editable').length, 'a form view should be open in a modal'); + assert.strictEqual($('.modal .o_form_editable .o_form_input').val(), 'xmo', + 'the name should be "xmo"'); + $('.modal .o_form_editable .o_form_input').val('ged').trigger('input'); // change the value + nbRPCs = 0; + $('.modal .modal-header .close').click(); // click on the cross to close the modal + assert.ok(!$('.modal').length, 'the modal should be closed'); + 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 + kanban.$('.o_kanban_group[data-id=5] .o_column_edit').click(); // click on 'Edit' + $('.modal .o_form_editable .o_form_input').val('ged').trigger('input'); // change the value + nbRPCs = 0; + $('.modal .modal-footer .btn-default').click(); // click on discard + assert.ok(!$('.modal').length, 'the modal should be closed'); + 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 + kanban.$('.o_kanban_group[data-id=5] .o_column_edit').click(); // click on 'Edit' + $('.modal .o_form_editable .o_form_input').val('ged').trigger('input'); // change the value + nbRPCs = 0; + $('.modal .modal-footer .btn-primary').click(); // 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('if view was grouped at start, it stays grouped', function (assert) { + assert.expect(1); + + var kanban = 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'], + }); + kanban.update({groupBy: []}); + + assert.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_grouped')); + kanban.destroy(); + }); + + QUnit.test('if view was not grouped at start, it can be grouped and ungrouped', function (assert) { + assert.expect(3); + + var kanban = 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.notOk(kanban.$('.o_kanban_view').hasClass('o_kanban_grouped'), "should not be grouped"); + kanban.update({groupBy: ['product_id']}); + assert.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_grouped'), "should be grouped"); + kanban.update({groupBy: []}); + assert.notOk(kanban.$('.o_kanban_view').hasClass('o_kanban_grouped'), "should not be grouped"); + kanban.destroy(); + }); + + QUnit.test('no content helper when no data', function (assert) { + assert.expect(5); + + var records = this.data.partner.records; + + this.data.partner.records = []; + + var kanban = 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.ok(kanban.$el.hasClass('o_kanban_nocontent'), + "$el should have correct no content class"); + + assert.strictEqual(kanban.$('.oe_view_nocontent').length, 1, + "should display the no content helper"); + + assert.strictEqual(kanban.$('.oe_view_nocontent p.hello:contains(add a partner)').length, 1, + "should have rendered no content helper from action"); + + this.data.partner.records = records; + kanban.reload(); + + assert.notOk(kanban.$el.hasClass('o_kanban_nocontent'), + "$el should have removed no content class"); + + assert.strictEqual(kanban.$('.oe_view_nocontent').length, 0, + "should not display the no content helper"); + kanban.destroy(); + }); + + QUnit.test('no nocontent helper for grouped kanban with empty groups', function (assert) { + assert.expect(2); + + var kanban = 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 === '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, function (group) { + group[args.kwargs.groupby[0] + '_count'] = 0; + }); + return result; + }); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, + "there should be two columns"); + assert.strictEqual(kanban.$('.o_kanban_record').length, 0, + "there should be no records"); + + kanban.destroy(); + }); + + QUnit.test('buttons with modifiers', function (assert) { + assert.expect(2); + + this.data.partner.records[1].bar = false; // so that test is more complete + + var kanban = 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.strictEqual(kanban.$(".o_btn_test_1").length, 1, + "kanban should have one buttons of type 1"); + assert.strictEqual(kanban.$(".o_btn_test_2").length, 3, + "kanban should have three buttons of type 2"); + kanban.destroy(); + }); + + QUnit.test('rendering date and datetime', 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 = 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', function (assert) { + assert.expect(3); + + this.data.partner.records[0].product_id = false; + + var kanban = 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" 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', function (assert) { + assert.expect(4); + + var kanban = 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) { + if (route === '/web/dataset/resequence') { + return $.when(); + } + return this._super.apply(this, arguments); + }, + }); + + assert.ok(kanban.$('.o_kanban_view').hasClass('ui-sortable'), + "columns should be sortable"); + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, + "should have two columns"); + assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 3, + "first column should be id 3 before resequencing"); + + // 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 + kanban._onResequenceColumn({data: {ids: [5, 3]}}); + kanban.update({}, {reload: false}); // re-render without reloading + + assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 5, + "first column should be id 5 before resequencing"); + + kanban.destroy(); + }); + + QUnit.test('properly evaluate more complex domains', function (assert) { + assert.expect(1); + + var kanban = 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 pull-right btn-sm" name="channel_join_and_get_info">Join</button>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + }); + + assert.strictEqual(kanban.$('button.oe_kanban_action_button').length, 1, + "only one button should be visible"); + kanban.destroy(); + }); + +}); + +}); 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 00000000000..0e710ca1653 --- /dev/null +++ b/addons/web/static/tests/views/list_tests.js @@ -0,0 +1,1264 @@ +odoo.define('web.list_tests', function (require) { +"use strict"; + +var config = require('web.config'); +var ListView = require('web.ListView'); +var testUtils = require('web.test_utils'); + +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}, + qux: {string: "my float", type: "float"}, + m2o: {string: "M2O field", type: "many2one", 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'}, + }, + 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", + }, + {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, + m2o: 2, m2m: [1, 2, 3], amount: 500}, + {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, + m2o: 1, m2m: [], amount: 300}, + {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'}, + ], + } + }; + } +}, function () { + + QUnit.module('ListView'); + + QUnit.test('simple readonly list', function (assert) { + assert.expect(9); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="foo"/><field name="int_field"/></tree>', + }); + + // 3 th (1 for checkbox, 2 for columns) + assert.strictEqual(list.$('th').length, 3, "should have 3 columns"); + + assert.strictEqual(list.$('td:contains(gnap)').length, 1, "should contain gnap"); + assert.strictEqual(list.$('tbody tr').length, 4, "should have 4 rows"); + assert.strictEqual(list.$('th.o_column_sortable').length, 1, "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.ok(list.$buttons.find('.o_list_button_add').is(':visible'), + "should have a visible Create button"); + assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'), + "should not have a visible save button"); + assert.ok(!list.$buttons.find('.o_list_button_discard').is(':visible'), + "should not have a visible save button"); + list.destroy(); + }); + + + QUnit.test('simple editable rendering', function (assert) { + assert.expect(9); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>', + }); + + assert.strictEqual(list.$('th').length, 3, "should have 2 th"); + assert.strictEqual(list.$('th').length, 3, "should have 3 th"); + assert.strictEqual(list.$('td:contains(yop)').length, 1, "should contain yop"); + + assert.ok(list.$buttons.find('.o_list_button_add').is(':visible'), + "should have a visible Create button"); + assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'), + "should not have a visible save button"); + assert.ok(!list.$buttons.find('.o_list_button_discard').is(':visible'), + "should not have a visible discard button"); + + list.$('td:not(.o_list_record_selector)').first().click(); + + assert.ok(!list.$buttons.find('.o_list_button_add').is(':visible'), + "should not have a visible Create button"); + assert.ok(list.$buttons.find('.o_list_button_save').is(':visible'), + "should have a visible save button"); + assert.ok(list.$buttons.find('.o_list_button_discard').is(':visible'), + "should have a visible discard button"); + list.destroy(); + }); + + QUnit.test('invisible columns are not displayed', function (assert) { + assert.expect(1); + + var list = 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.strictEqual(list.$('th').length, 2, "should have 2 th"); + list.destroy(); + }); + + QUnit.test('at least 4 rows are rendered, even if less data', function (assert) { + assert.expect(1); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="bar"/></tree>', + domain: [['bar', '=', true]], + }); + + assert.strictEqual(list.$('tbody tr').length, 4, "should have 4 rows"); + list.destroy(); + }); + + QUnit.test('basic grouped list rendering', function (assert) { + assert.expect(4); + + var list = 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.strictEqual(list.$('tr.o_group_header').length, 2, "should have 2 .o_group_header"); + assert.strictEqual(list.$('th.o_group_name').length, 2, "should have 2 .o_group_name"); + list.destroy(); + }); + + QUnit.test('many2one field rendering', function (assert) { + assert.expect(1); + + var list = 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', function (assert) { + assert.expect(6); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="foo"/><field name="int_field"/></tree>', + groupBy: ['foo'], + }); + + list.$('th.o_group_name').get(1).click(); + assert.strictEqual(list.$('tbody:eq(1) tr').length, 2, "open group should contain 2 records"); + assert.strictEqual(list.$('tbody').length, 3, "should contain 3 tbody"); + assert.strictEqual(list.$('td:contains(9)').length, 1, "should contain 9"); + assert.strictEqual(list.$('td:contains(-4)').length, 1, "should contain -4"); + assert.strictEqual(list.$('td:contains(10)').length, 1, "should contain 10"); + assert.strictEqual(list.$('tr.o_group_header td:contains(10)').length, 1, "but 10 should be in a header"); + list.destroy(); + }); + + QUnit.test('opening records when clicking on record', function (assert) { + assert.expect(3); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="foo"/></tree>', + }); + + testUtils.intercept(list, "open_record", function () { + assert.ok("list view should trigger 'open_record' event"); + }); + + list.$('tr td:not(.o_list_record_selector)').first().click(); + list.update({groupBy: ['foo']}); + assert.strictEqual(list.$('tr.o_group_header').length, 3, "list should be grouped"); + list.$('th.o_group_name').first().click(); + + list.$('tr:not(.o_group_header) td:not(.o_list_record_selector)').first().click(); + list.destroy(); + }); + + QUnit.test('editable list view: readonly fields cannot be edited', function (assert) { + assert.expect(3); + + this.data.foo.fields.foo.readonly = true; + + var list = 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); + $td.click(); + assert.ok(!$td.hasClass('o_edit_mode'), "foo cells should not be editable"); + assert.ok($second_td.hasClass('o_edit_mode'), "bar cells should be editable"); + assert.ok(!$third_td.hasClass('o_edit_mode'), "int_field cells should not be editable"); + list.destroy(); + }); + + QUnit.test('basic operations for editable list renderer', function (assert) { + assert.expect(4); + + var list = 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.strictEqual($td.hasClass('o_edit_mode'), false, "td should not be in edit mode"); + $td.click(); + assert.strictEqual($td.hasClass('o_edit_mode'), true, "td should be in edit mode"); + assert.strictEqual($td.hasClass('o_field_dirty'), false, "td should not be dirty"); + $td.find('input').val('abc').trigger('input'); + assert.strictEqual($td.hasClass('o_field_dirty'), true, "td should be dirty"); + list.destroy(); + }); + + QUnit.test('field changes are triggered correctly', function (assert) { + assert.expect(2); + + var list = 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.intercept(list, "field_changed", function () { + n = 1; + }); + $td.click(); + $td.find('input').val('abc').trigger('input'); + assert.strictEqual(n, 1, "field_changed should not have been triggered"); + list.$('td:not(.o_list_record_selector)').eq(2).click(); + assert.strictEqual(n, 1, "field_changed should have been triggered"); + list.destroy(); + }); + + QUnit.test('editable list view: basic char field edition', function (assert) { + assert.expect(4); + + var list = 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(); + $td.click(); + $td.find('input').val('abc').trigger('input'); + 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(); + $next_row_td.click(); // should trigger the save of the previous row + assert.strictEqual(list.$('td:not(.o_list_record_selector)').first().text(), 'abc', + 'changes should be saved correctly'); + assert.ok(!list.$('tbody tr').first().hasClass('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('selection changes are triggered correctly', function (assert) { + assert.expect(8); + + var list = 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.intercept(list, "selection_changed", function () { + n += 1; + }); + + // tbody checkbox click + $tbody_selector.click(); + assert.strictEqual(n, 1, "selection_changed should have been triggered"); + assert.ok($tbody_selector.is(':checked'), "selection checkbox should be checked"); + $tbody_selector.click(); + 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 + $thead_selector.click(); + assert.strictEqual(n, 3, "selection_changed should have been triggered"); + assert.strictEqual(list.$('tbody .o_list_record_selector input:checked').length, + list.$('tbody tr').length, "all selection checkboxes should be checked"); + + $thead_selector.click(); + assert.strictEqual(n, 4, "selection_changed should have been triggered"); + + assert.strictEqual(list.$('tbody .o_list_record_selector input:checked').length, 0, + "no selection checkbox should be checked"); + list.destroy(); + }); + + QUnit.test('aggregates are computed correctly', function (assert) { + assert.expect(3); + + var list = 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"); + + $tbody_selectors.first().click(); + $tbody_selectors.last().click(); + assert.strictEqual(list.$('tfoot td:nth(2)').text(), "6", + "total should be 6 as first and last records are selected"); + + $thead_selector.click(); + assert.strictEqual(list.$('tfoot td:nth(2)').text(), "32", + "total should be 32 as all records are selected"); + list.destroy(); + }); + + QUnit.test('aggregates are computed correctly in grouped lists', function (assert) { + assert.expect(4); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + groupBy: ['m2o'], + arch: '<tree editable="bottom"><field name="int_field" sum="Sum"/></tree>', + }); + + var $group_1_header = list.$('.o_group_header').filter(function (index, el) { + return $(el).data('group').res_id === 1; + }); + var $group_2_header = list.$('.o_group_header').filter(function (index, el) { + return $(el).data('group').res_id === 2; + }); + assert.strictEqual($group_1_header.find('td:nth(1)').text(), "23", "first group total should be 23"); + assert.strictEqual($group_2_header.find('td:nth(1)').text(), "9", "second group total should be 9"); + assert.strictEqual(list.$('tfoot td:nth(2)').text(), "32", "total should be 32"); + + $group_1_header.click(); + list.$('tbody .o_list_record_selector input').first().click(); + assert.strictEqual(list.$('tfoot td:nth(2)').text(), "10", + "total should be 10 as first record of first group is selected"); + list.destroy(); + }); + + QUnit.test('properly apply onchange in simple case', function (assert) { + assert.expect(2); + + this.data.foo.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length + 1000; + }, + }; + var list = 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"); + + $foo_td.click(); + $foo_td.find('input').val('tralala').trigger('input'); + + 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', function (assert) { + assert.expect(10); + + // Warning: this test is css dependant + var list = 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'); + + // start edition of first row + list.$('td:not(.o_list_record_selector)').first().click(); + + var editionWidths = _.pluck(list.$('thead th'), 'offsetWidth'); + + // leave edition + list.$buttons.find('.o_list_button_save').click(); + var readonlyWidths = _.pluck(list.$('thead th'), 'offsetWidth'); + + for (var i = 0; i < startWidths.length; i++) { + assert.strictEqual(startWidths[i], editionWidths[i], + 'width of columns should remain unchanged which switching from readonly to edit mode'); + assert.strictEqual(editionWidths[i], readonlyWidths[i], + 'width of columns should remain unchanged which switching from edit to readonly mode'); + } + list.destroy(); + }); + + QUnit.test('deleting one record', function (assert) { + assert.expect(5); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + viewOptions: {sidebar: true}, + arch: '<tree><field name="foo"/></tree>', + }); + + assert.ok(list.sidebar.$el.hasClass('o_hidden'), 'sidebar should be invisible'); + assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 4, "should have 4 records"); + + list.$('tbody td.o_list_record_selector:first input').click(); + + assert.ok(!list.sidebar.$el.hasClass('o_hidden'), 'sidebar should be visible'); + + list.sidebar.$('a:contains(Delete)').click(); + assert.ok($('body').hasClass('modal-open'), 'body should have modal-open clsss'); + + $('body .modal-dialog button span:contains(Ok)').click(); + + assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 3, "should have 3 records"); + list.destroy(); + }); + + QUnit.test('pager (ungrouped and grouped mode), default limit', function (assert) { + assert.expect(4); + + var list = 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.ok(!list.pager.$el.hasClass('o_hidden'), "pager should be visible"); + assert.strictEqual(list.pager.state.size, 4, "pager's size should be 4"); + list.update({ groupBy: ['bar']}); + assert.ok(list.pager.$el.hasClass('o_hidden'), "pager should be invisible"); + list.destroy(); + }); + + QUnit.test('can sort records when clicking on header', function (assert) { + assert.expect(9); + + this.data.foo.fields.foo.sortable = true; + + var nbSearchRead = 0; + var list = 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; + list.$('thead th:contains(Foo)').click(); + 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; + list.$('thead th:contains(Foo)').click(); + 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('can display button in edit mode', function (assert) { + assert.expect(1); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<button name="notafield" type="object" icon="fa-asterisk"/>' + + '</tree>', + }); + assert.ok(list.$('tbody button').length, "should have a button"); + list.destroy(); + }); + + QUnit.test('can display a list with a many2many field', function (assert) { + assert.expect(2); + + var rpcCount = 0; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree>' + + '<field name="m2m"/>' + + '</tree>', + mockRPC: function (route, args) { + rpcCount++; + return this._super(route, args); + }, + }); + assert.strictEqual(rpcCount, 2, "should have done 2 rpcs: 1 searchread and 1 read for m2m"); + assert.ok(list.$('td:contains(3 records)').length, + "should have a td with correct formatted value"); + list.destroy(); + }); + + + QUnit.test('list without import button', function (assert) { + assert.expect(1); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="foo"/></tree>', + context: { + group_by_no_leaf: true, + } + }); + + assert.ok(!list.$buttons, "should not have any buttons"); + list.destroy(); + }); + + QUnit.test('display a tooltip on a field', function (assert) { + assert.expect(2); + + var initialDebugMode = config.debug; + config.debug = false; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="foo"/></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:not(.o_list_record_selector)').tooltip('show', false); + + list.$('th:not(.o_list_record_selector)').trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_string').length, 0, "should not have rendered a tooltip"); + + config.debug = true; + // it is necessary to rerender the list so tooltips can be properly created + list.reload(); + list.$('th:not(.o_list_record_selector)').tooltip('show', false); + + list.$('th:not(.o_list_record_selector)').trigger($.Event('mouseenter')); + assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1, "should have rendered a tooltip"); + + config.debug = initialDebugMode; + list.destroy(); + }); + + QUnit.test('support row decoration', function (assert) { + assert.expect(2); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree decoration-info="int_field > 5">' + + '<field name="foo"/><field name="int_field"/>' + + '</tree>', + }); + + assert.strictEqual(list.$('tbody tr.text-info').length, 3, + "should have 3 columns with text-info class"); + + assert.strictEqual(list.$('tbody tr').length, 4, "should have 4 rows"); + list.destroy(); + }); + + QUnit.test('support row decoration with date', function (assert) { + assert.expect(3); + + this.data.foo.records[0].datetime = '2017-02-27 12:51:35'; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree decoration-info="datetime == \'2017-02-27 12:51:35\'" decoration-danger="datetime > \'2017-02-27 12:51:35\' AND datetime < \'2017-02-27 10:51:35\'">' + + '<field name="datetime"/><field name="int_field"/>' + + '</tree>', + }); + + assert.strictEqual(list.$('tbody tr.text-info').length, 1, + "should have 1 columns with text-info class with good datetime"); + + assert.strictEqual(list.$('tbody tr.text-danger').length, 0, + "should have 0 columns with text-danger class with wrong timezone datetime"); + + assert.strictEqual(list.$('tbody tr').length, 4, "should have 4 rows"); + list.destroy(); + }); + + QUnit.test('no content helper when no data', function (assert) { + assert.expect(5); + + var records = this.data.foo.records; + + this.data.foo.records = []; + + var list = 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.strictEqual(list.$('.oe_view_nocontent').length, 1, + "should display the no content helper"); + + assert.strictEqual(list.$('table').length, 0, "should not have a table in the dom"); + + assert.strictEqual(list.$('.oe_view_nocontent p.hello:contains(add a partner)').length, 1, + "should have rendered no content helper from action"); + + this.data.foo.records = records; + list.reload(); + + assert.strictEqual(list.$('.oe_view_nocontent').length, 0, + "should not display the no content helper"); + assert.strictEqual(list.$('table').length, 1, "should have a table in the dom"); + list.destroy(); + }); + + QUnit.test('no nocontent helper when no data and no help', function (assert) { + assert.expect(3); + + this.data.foo.records = []; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="foo"/></tree>', + }); + + assert.strictEqual(list.$('.oe_view_nocontent').length, 0, + "should not display the no content helper"); + + assert.strictEqual(list.$('tr.o_data_row').length, 0, + "should not have any data row"); + + assert.strictEqual(list.$('table').length, 1, "should have a table in the dom"); + list.destroy(); + }); + + QUnit.test('list view, editable, without data', function (assert) { + assert.expect(9); + + this.data.foo.records = []; + + this.data.foo.fields.date.default = "2017-02-10"; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree string="Phonecalls" editable="top">' + + '<field name="date"/>' + + '<field name="m2o"/>' + + '<field name="foo"/>' + + '</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.strictEqual(list.$('.oe_view_nocontent').length, 1, + "should have a no content helper displayed"); + + assert.strictEqual(list.$('div.table-responsive').length, 0, + "should not have a div.table-responsive"); + assert.strictEqual(list.$('table').length, 0, "should not have rendered a table"); + + list.$buttons.find('.o_list_button_add').click(); + + assert.strictEqual(list.$('.oe_view_nocontent').length, 0, + "should not have a no content helper displayed"); + assert.strictEqual(list.$('table').length, 1, "should have rendered a table"); + assert.strictEqual(list.$el.css('height'), list.$('div.table-responsive').css('height'), + "the div for the table should take the full height"); + + + assert.ok(list.$('tbody tr:eq(0) td:eq(1)').hasClass('o_edit_mode'), + "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"); + + list.$buttons.find('.o_list_button_save').click(); + list.destroy(); + }); + + QUnit.test('list view, editable, with a button', function (assert) { + assert.expect(1); + + this.data.foo.records = []; + + var list = 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>', + }); + + list.$buttons.find('.o_list_button_add').click(); + + assert.strictEqual(list.$('table button.o_icon_button i.fa-phone').length, 1, + "should have rendered a button"); + list.destroy(); + }); + + QUnit.test('list view, editable, can discard', function (assert) { + assert.expect(5); + + var list = 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"); + + list.$('tbody td:not(.o_list_record_selector):first').click(); + 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"); + + list.$buttons.find('.o_list_button_discard').click(); + + 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', function (assert) { + assert.expect(3); + + this.data.foo.fields.date.default = "2017-02-10"; + this.data.foo.records = []; + + var createCount = 0; + + var list = 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); + }, + }); + + list.$buttons.find('.o_list_button_add').click(); + list.$('div.table-responsive').click(); + + assert.strictEqual(createCount, 1, "should have created a record"); + + list.$buttons.find('.o_list_button_add').click(); + list.$('tfoot').click(); + + assert.strictEqual(createCount, 2, "should have created a record"); + + list.$buttons.find('.o_list_button_add').click(); + list.$('tbody tr').last().click(); + + assert.strictEqual(createCount, 3, "should have created a record"); + list.destroy(); + }); + + QUnit.test('click on a button in a list view', function (assert) { + assert.expect(6); + + var list = 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>', + intercepts: { + execute_action: function (event) { + assert.strictEqual(event.data.record_id, 1, + 'should call with correct id'); + assert.strictEqual(event.data.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'); + }, + }, + }); + + assert.strictEqual(list.$('.o_list_button').length, 4, + "there should be one button per row"); + assert.strictEqual(list.$('.o_list_button:first .o_icon_button .fa.fa-car').length, 1, + 'buttons should have correct icon'); + + list.$('.o_list_button:first > button').click(); // click on the button + list.destroy(); + }); + + QUnit.test('invisible attrs in readonly and editable list', function (assert) { + assert.expect(5); + + var list = 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.equal(list.$('tbody tr:nth(0) td:nth(1)').html(), "", + "td that contains an invisible button should be empty"); + + // edit first row + list.$('tbody tr:nth(0) td:nth(2)').click(); // click on first row to edit it + assert.equal(list.$('tbody tr:nth(0) td:nth(4)').html(), "", + "td that contains an invisible field should be empty"); + assert.equal(list.$('tbody tr:nth(0) td:nth(1)').html(), "", + "td that contains an invisible button should be empty"); + list.$buttons.find('.o_list_button_discard').click(); // leave edition + + // click on the invisible field's cell to edit first row + list.$('tbody tr:nth(0) td:nth(4)').click(); + assert.ok(list.$('tbody tr:nth(0)').hasClass('o_selected_row'), + "first row should be in edition"); + list.destroy(); + }); + + QUnit.test('monetary fields are properly rendered', function (assert) { + assert.expect(3); + + var currencies = {}; + _.each(this.data.res_currency.records, function (currency) { + currencies[currency.id] = currency; + }); + var list = 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.strictEqual(list.$('tbody tr:first td').length, 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', function (assert) { + assert.expect(2); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="date"/><field name="datetime"/></tree>', + }); + + // FIXME: this test is locale dependant. we need to do it right. + assert.strictEqual(list.$('td:contains(01/25/2017)').length, 1, + "should have formatted the date"); + assert.strictEqual(list.$('td:contains(12/12/2016 10:55:05)').length, 1, + "should have formatted the datetime"); + list.destroy(); + }); + + QUnit.test('edit a row by clicking on a readonly field', function (assert) { + assert.expect(8); + + this.data.foo.fields.foo.readonly = true; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom"><field name="foo"/><field name="int_field"/></tree>', + }); + + assert.ok(list.$('.o_data_row:first td:nth(1)').hasClass('o_readonly'), + "foo field cells should have class 'o_readonly'"); + + // edit the first row + list.$('.o_data_row:first td:nth(1)').click(); + assert.ok(list.$('.o_data_row:first').hasClass('o_selected_row'), + "first row should be selected"); + assert.ok(list.$('.o_data_row:first td:nth(1)').hasClass('o_readonly'), + "foo field cells should have class 'o_readonly'"); + assert.strictEqual(list.$('.o_data_row:first td:nth(1)').text(), 'yop', + "no widget should be rendered for readonly fields"); + assert.ok(list.$('.o_data_row:first td:nth(2)').hasClass('o_edit_mode'), + "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 + list.$('.o_data_row:first td:nth(1)').click(); + assert.ok(list.$('.o_data_row:first').hasClass('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', function (assert) { + assert.expect(37); + + 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 list = 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 === '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"); + + // basic rendering tests + assert.strictEqual(list.$('tbody').length, 1, "there should be 1 tbody"); + assert.strictEqual(list.$('.o_group_header').length, 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.strictEqual(list.$('.o_group_name .fa-caret-right').length, 2, + "the carret of closed groups should be right"); + assert.strictEqual(list.$('.o_group_name:first span').css('padding-left'), + '0px', "groups of level 1 should have a 0px 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}; + list.$('.o_group_header:first').click(); + assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group"); + assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read"); + + 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.strictEqual(list.$('tbody').length, 3, "there should be 3 tbodys"); + assert.strictEqual(list.$('.o_group_name:first .fa-caret-down').length, 1, + "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'), + '20px', "groups of level 2 should have a 20px 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}; + $openGroup.find('.o_group_header:nth(2)').click(); + assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group"); + assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read"); + + var $openSubGroup = list.$('tbody:nth(2)'); + assert.strictEqual(list.$('tbody').length, 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') + $openSubGroup.find('.o_data_row:first').click(); + + // sort by int_field (ASC) and check that open groups are still open + nbRPCs = {readGroup: 0, searchRead: 0}; + list.$('thead th:last').click(); + assert.strictEqual(nbRPCs.readGroup, 2, "should have done two read_groups"); + assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read"); + + $openSubGroup = list.$('tbody:nth(2)'); + assert.strictEqual(list.$('tbody').length, 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}; + list.$('.o_group_header:first').click(); + assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group"); + assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read"); + + assert.strictEqual(list.$('tbody').length, 1, "there should be 1 tbody"); + assert.strictEqual(list.$('.o_group_header').length, 2, + "should contain 2 groups at first level"); + assert.strictEqual(list.$('.o_group_name .fa-caret-right').length, 2, + "the carret of closed groups should be right"); + + list.destroy(); + }); + + QUnit.test('grouped list with a pager in a group', function (assert) { + assert.expect(6); + this.data.foo.records[3].bar = true; + + var list = 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 + list.$('.o_group_header').click(); + assert.strictEqual(list.$('.o_group_header').css('height'), headerHeight, + "height of group header shouldn't have changed"); + assert.ok(list.$('.o_group_header td:last').hasClass('o_group_pager'), + "last cell of open group header should have classname 'o_group_header'"); + assert.strictEqual(list.$('.o_pager_value').text(), '1-3', + "pager's value should be correct"); + assert.strictEqual(list.$('.o_data_row').length, 3, + "open group should display 3 records"); + + // go to next page + list.$('.o_pager_next').click(); + assert.strictEqual(list.$('.o_pager_value').text(), '4-4', + "pager's value should be correct"); + assert.strictEqual(list.$('.o_data_row').length, 1, + "open group should display 1 record"); + + list.destroy(); + }); + + QUnit.test('edition: create new line, then discard', function (assert) { + assert.expect(2); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>', + }); + + + assert.strictEqual(list.$('tr.o_data_row').length, 4, + "should have 4 records"); + list.$buttons.find('.o_list_button_add').click(); + list.$buttons.find('.o_list_button_discard').click(); + assert.strictEqual(list.$('tr.o_data_row').length, 4, + "should still have 4 records"); + list.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 00000000000..5c835dfb27e --- /dev/null +++ b/addons/web/static/tests/views/pivot_tests.js @@ -0,0 +1,536 @@ +odoo.define('web.pivot_tests', function (require) { +"use strict"; + +var core = require('web.core'); +var PivotView = require('web.PivotView'); +var testUtils = require('web.test_utils'); + +var _t = core._t; +var createView = testUtils.createView; + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "integer"}, + bar: {string: "bar", type: "boolean"}, + date: {string: "Date", type: "date", store: true}, + product_id: {string: "Product", type: "many2one", relation: 'product', store: true}, + non_stored_m2o: {string: "Non Stored M2O", type: "many2one", relation: 'product'}, + }, + records: [ + { + id: 1, + foo: 12, + bar: true, + date: '2016-12-14', + product_id: 37, + }, { + id: 2, + foo: 1, + bar: true, + date: '2016-10-26', + product_id: 41, + }, { + id: 3, + foo: 17, + bar: true, + date: '2016-12-15', + product_id: 41, + }, {id: 4, + foo: 2, + bar: false, + date: '2016-04-11', + product_id: 41, + }, + ] + }, + product: { + fields: { + name: {string: "Product Name", type: "char"} + }, + records: [{ + id: 37, + display_name: "xphone", + }, { + id: 41, + display_name: "xpad", + }] + }, + }; + } +}, function () { + QUnit.module('PivotView'); + + QUnit.test('simple pivot rendering', function (assert) { + assert.expect(2); + + var pivot = 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.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 view without "string" attribute', function (assert) { + assert.expect(1); + + var pivot = 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('pivot view grouped by date field', function (assert) { + assert.expect(2); + + var data = this.data; + var pivot = 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 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', function (assert) { + assert.expect(2); + + var pivot = 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('li[data-field=__count]'); + assert.ok($countMeasure.hasClass('selected'), "The count measure should be activated"); + pivot.destroy(); + }); + + QUnit.test('pivot view can be reloaded', function (assert) { + assert.expect(4); + var readGroupCount = 0; + + var pivot = 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"); + + pivot.update({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', function (assert) { + assert.expect(3); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('.o_pivot_header_cell_opened').length, 1, + "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', function (assert) { + assert.expect(7); + + var rpcCount = 0; + + var pivot = 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.strictEqual(pivot.$('tbody tr').length, 3, + "should have 3 rows: 1 for the opened header, and 2 for data"); + + // click on the opened header to close it + pivot.$('.o_pivot_header_cell_opened').click(); + + assert.strictEqual(pivot.$('tbody tr').length, 1, "should have 1 row"); + + // click on closed header to open dropdown + pivot.$('tbody .o_pivot_header_cell_closed').click(); + + assert.strictEqual(pivot.$('ul.o_pivot_field_menu > li[data-field="date"]').length, 1, + "should have the date field as proposition"); + assert.strictEqual(pivot.$('.o_field_selection li[data-field="product_id"]').length, 1, + "should have the product_id field as proposition"); + assert.strictEqual(pivot.$('.o_field_selection li[data-field="non_stored_m2o"]').length, 0, + "should not have the non_stored_m2o field as proposition"); + + pivot.$('ul.o_pivot_field_menu > li[data-field="date"] a').click(); + + assert.strictEqual(pivot.$('tbody tr').length, 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', function (assert) { + assert.expect(1); + + var pivot = 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 + pivot.$('tbody .o_pivot_header_cell_closed').first().click(); + // click on date by day + pivot.$('ul.o_pivot_field_menu > li[data-field="date"] a[data-interval="day"]').click(); + + // open dropdown to zoom into second row + pivot.$('tbody td.o_pivot_header_cell_closed:eq(1)').first().click(); + + assert.strictEqual(pivot.$('tbody tr').length, 7, + "should have 7 rows (1 for total, 1 for xphone, 1 for xpad, 4 for data)"); + pivot.destroy(); + }); + + QUnit.test('pivot view can be flipped', function (assert) { + assert.expect(3); + + var rpcCount = 0; + + var pivot = 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.strictEqual(pivot.$('tbody tr').length, 3, + "should have 3 rows: 1 for the open header, and 2 for data"); + + rpcCount = 0; + pivot.$buttons.find('.o_pivot_flip_button').click(); + + assert.strictEqual(rpcCount, 0, "should not have done any rpc"); + assert.strictEqual(pivot.$('tbody tr').length, 1, + "should have 1 rows: 1 for the main header"); + pivot.destroy(); + }); + + QUnit.test('can toggle extra measure', function (assert) { + assert.expect(8); + + var rpcCount = 0; + + var pivot = 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.strictEqual(pivot.$('.o_pivot_cell_value').length, 3, + "should have 3 cells: 1 for the open header, and 2 for data"); + assert.ok(!pivot.$buttons.find('li[data-field=__count]').hasClass('selected'), + "the __count measure should not be selected"); + + rpcCount = 0; + pivot.$buttons.find('li[data-field=__count] a').click(); + + assert.ok(pivot.$buttons.find('li[data-field=__count]').hasClass('selected'), + "the __count measure should be selected"); + assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 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"); + + pivot.$buttons.find('li[data-field=__count] a').click(); + + assert.ok(!pivot.$buttons.find('li[data-field=__count]').hasClass('selected'), + "the __count measure should not be selected"); + assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 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', function (assert) { + assert.expect(4); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 0, + "should not have a no_content_helper"); + assert.strictEqual(pivot.$('table').length, 1, + "should have a table in DOM"); + + pivot.$buttons.find('li[data-field=__count] a').click(); + + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 1, + "should have a no_content_helper"); + assert.strictEqual(pivot.$('table').length, 0, + "should not have a table in DOM"); + pivot.destroy(); + }); + + QUnit.test('no content helper when no data', function (assert) { + assert.expect(4); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 0, + "should not have a no_content_helper"); + assert.strictEqual(pivot.$('table').length, 1, + "should have a table in DOM"); + + pivot.update({domain: [['foo', '=', 12345]]}); + + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 1, + "should have a no_content_helper"); + assert.strictEqual(pivot.$('table').length, 0, + "should not have a table in DOM"); + pivot.destroy(); + }); + + QUnit.test('no content helper when no data, part 2', function (assert) { + assert.expect(1); + + this.data.partner.records = []; + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners"></pivot>', + }); + + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 1, + "should have a no_content_helper"); + pivot.destroy(); + }); + + QUnit.test('no content helper when no data, part 3', function (assert) { + assert.expect(4); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners"></pivot>', + viewOptions: { + domain: [['foo', '=', 12345]] + }, + }); + + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 1, + "should have a no_content_helper"); + pivot.update({domain: [['foo', '=', 12345]]}); + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 1, + "should still have a no_content_helper"); + pivot.update({domain: []}); + assert.strictEqual(pivot.$('.oe_view_nocontent').length, 0, + "should not have a no_content_helper"); + + // tries to open a field selection menu, to make sure it was not + // removed from the dom. + pivot.$('.o_pivot_header_cell_closed').first().click(); + assert.strictEqual(pivot.$('ul.o_pivot_field_menu').length, 1, + "the field selector menu exists"); + pivot.destroy(); + }); + + QUnit.test('tries to restore previous state after domain change', function (assert) { + assert.expect(5); + + var rpcCount = 0; + + var pivot = 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.strictEqual(pivot.$('.o_pivot_cell_value').length, 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"); + + pivot.update({domain: [['foo', '=', 12345]]}); + + rpcCount = 0; + pivot.update({domain: []}); + + assert.equal(rpcCount, 2, "should have reloaded data"); + assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 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', function (assert) { + assert.expect(4); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 1, + "should have only 1 cell"); + assert.strictEqual(pivot.$('tbody tr').length, 1, + "should have 1 rows"); + + pivot.update({groupBy: ['product_id']}); + + assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 3, + "should have 3 cells"); + assert.strictEqual(pivot.$('tbody tr').length, 3, + "should have 3 rows"); + pivot.destroy(); + }); + + QUnit.test('can sort data in a column by clicking on header', function (assert) { + assert.expect(3); + + var pivot = 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"); + + pivot.$('th.o_pivot_measure_row').click(); + + assert.strictEqual($('td.o_pivot_cell_value').text(), "322012", + "should have proper values in cells (total, result 2, result 1"); + + pivot.$('th.o_pivot_measure_row').click(); + + assert.strictEqual($('td.o_pivot_cell_value').text(), "321220", + "should have proper values in cells (total, result 1, result 2"); + + pivot.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 00000000000..2a86f044af0 --- /dev/null +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -0,0 +1,63 @@ +odoo.define('web.view_dialogs_tests', function (require) { +"use strict"; + +var testUtils = require('web.test_utils'); +var dialogs = require('web.view_dialogs'); +var Widget = require('web.Widget'); + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: 'char'}, + }, + records: [ + {id: 1, foo: 'blip',}, + ], + }, + }; + + }, +}, function () { + + QUnit.module('view_dialogs'); + + function createParent(params) { + var widget = new Widget(); + + testUtils.addMockEnvironment(widget, params); + return widget; + } + + QUnit.test('formviewdialog buttons in footer are positioned properly', function (assert) { + assert.expect(2); + + var parent = 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>', + }, + }); + + var dialog = new dialogs.FormViewDialog(parent, { + res_model: 'partner', + res_id: 1, + }).open(); + + assert.notOk($('div.modal .modal-body button').length, + "should not have any button in body"); + assert.strictEqual($('div.modal .modal-footer button').length, 1, + "should have only one button in footer"); + dialog.destroy(); + }); + +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/widgets/domain_selector_tests.js b/addons/web/static/tests/widgets/domain_selector_tests.js new file mode 100644 index 00000000000..19731156a2c --- /dev/null +++ b/addons/web/static/tests/widgets/domain_selector_tests.js @@ -0,0 +1,171 @@ +odoo.define('web.domain_selector_tests', function (require) { +"use strict"; + +var DomainSelector = require("web.DomainSelector"); +var testUtils = require("web.test_utils"); + +QUnit.module('widgets', {}, function () { + +QUnit.module('DomainSelector', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "char", searchable: true}, + bar: {string: "Bar", type: "boolean", searchable: true}, + nice_datetime: {string: "Datetime", type: "datetime", searchable: true}, + product_id: {string: "Product", type: "many2one", relation: "product", searchable: true}, + }, + records: [{ + id: 1, + foo: "yop", + bar: true, + product_id: 37, + }, { + id: 2, + foo: "blip", + bar: true, + product_id: false, + }, { + id: 4, + foo: "abc", + bar: false, + product_id: 41, + }], + onchanges: {}, + }, + product: { + fields: { + name: {string: "Product Name", type: "char", searchable: true} + }, + records: [{ + id: 37, + display_name: "xphone", + }, { + id: 41, + display_name: "xpad", + }] + }, + }; + }, +}, function () { + + QUnit.test("creating a domain from scratch", function (assert) { + assert.expect(13); + + var $target = $("#qunit-fixture"); + + // Create the domain selector and its mock environment + var domainSelector = new DomainSelector(null, "partner", [], { + readonly: false, + debugMode: true, + }); + testUtils.addMockEnvironment(domainSelector, {data: this.data}); + domainSelector.appendTo($target); + + // As we gave an empty domain, there should be a visible button to add + // the first domain part + var $domainAddFirstNodeButton = domainSelector.$(".o_domain_add_first_node_button:visible"); + assert.strictEqual($domainAddFirstNodeButton.length, 1, + "there should be a button to create first domain element"); + + // Clicking on the button should add a visible field selector in the + // widget so that the user can change the field chain + $domainAddFirstNodeButton.click(); + var $fieldSelector = domainSelector.$(".o_field_selector:visible"); + assert.strictEqual($fieldSelector.length, 1, + "there should be a field selector"); + + // Focusing the field selector input should open a field selector popover + $fieldSelector.trigger('focusin'); + var $fieldSelectorPopover = $fieldSelector.find(".o_field_selector_popover:visible"); + assert.strictEqual($fieldSelectorPopover.length, 1, + "field selector popover should be visible"); + + // The field selector popover should contain the list of "partner" + // fields. "Bar" should be among them. + var $lis = $fieldSelectorPopover.find("li"); + var $barLi = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Bar") >= 0) { + $barLi = $li; + } + }); + assert.strictEqual($barLi.length, 1, + "field selector popover should contain the 'Bar' field"); + + // Clicking the "Bar" field should change the internal domain and this + // should be displayed in the debug input + $barLi.click(); + assert.strictEqual( + domainSelector.$(".o_domain_debug_input").val(), + '[["bar","=",True]]', + "the domain input should contain a domain with 'bar'" + ); + + // There should be a "+" button to add a domain part; clicking on it + // should add the default "['id', '=', 1]" domain + var $plus = domainSelector.$(".fa-plus-circle"); + assert.strictEqual($plus.length, 1, "there should be a '+' button"); + $plus.click(); + assert.strictEqual( + domainSelector.$(".o_domain_debug_input").val(), + '["&",["bar","=",True],["id","=",1]]', + "the domain input should contain a domain with 'bar' and 'id'"); + + // There should be two "..." buttons to add a domain group; clicking on + // the first one, should add this group with defaults "['id', '=', 1]" + // domains and the "|" operator + var $dots = domainSelector.$(".fa-ellipsis-h"); + assert.strictEqual($dots.length, 2, "there should be two '...' buttons"); + $dots.first().click(); + assert.strictEqual( + domainSelector.$(".o_domain_debug_input").val(), + '["&","&",["bar","=",True],"|",["id","=",1],["id","=",1],["id","=",1]]', + "the domain input should contain a domain with 'bar', 'id' and a subgroup" + ); + + // Changing the domain input to update the subgroup to use the "foo" + // field instead of "id" should rerender the widget and adapt the + // widget suggestions + domainSelector.$(".o_domain_debug_input").val('["&","&",["bar","=",True],"|",["foo","=","hello"],["id","=",1],["id","=",1]]').change(); + assert.strictEqual(domainSelector.$(".o_field_selector").eq(1).find("input").val(), "foo", + "the second field selector should now contain the 'foo' value"); + assert.ok(domainSelector.$(".o_domain_leaf_operator_select").eq(1).html().indexOf("contains") >= 0, + "the second operator selector should now contain the 'contains' operator"); + + // There should be five "-" buttons to remove domain part; clicking on + // the two last ones, should leave a domain with only the "bar" and + // "foo" fields, with the initial "&" operator + var $minus = domainSelector.$(".o_domain_delete_node_button"); + assert.strictEqual($minus.length, 5, "there should be five 'x' buttons"); + $minus.last().click(); + domainSelector.$(".o_domain_delete_node_button").last().click(); + assert.strictEqual( + domainSelector.$(".o_domain_debug_input").val(), + '["&",["bar","=",True],["foo","=","hello"]]', + "the domain input should contain a domain with 'bar' and 'foo'" + ); + domainSelector.destroy(); + }); + + QUnit.test("building a domain with a datetime", function (assert) { + assert.expect(1); + + var $target = $("#qunit-fixture"); + + // Create the domain selector and its mock environment + var domainSelector = new DomainSelector(null, "partner", [["nice_datetime", "=", "2017-03-27 15:42:00"]], { + readonly: false, + }); + testUtils.addMockEnvironment(domainSelector, {data: this.data}); + domainSelector.appendTo($target); + + // Check that there is a datepicker to choose the date + assert.strictEqual(domainSelector.$(".o_datepicker:visible").length, 1, + "there should be a datepicker"); + }); +}); +}); +}); diff --git a/addons/web/static/tests/widgets/model_field_selector_tests.js b/addons/web/static/tests/widgets/model_field_selector_tests.js new file mode 100644 index 00000000000..d92e41760dc --- /dev/null +++ b/addons/web/static/tests/widgets/model_field_selector_tests.js @@ -0,0 +1,137 @@ +odoo.define('web.model_field_selector_tests', function (require) { +"use strict"; + +var ModelFieldSelector = require("web.ModelFieldSelector"); +var testUtils = require("web.test_utils"); + +QUnit.module('widgets', {}, function () { + +QUnit.module('ModelFieldSelector', { + beforeEach: function () { + this.data = { + partner: { + fields: { + foo: {string: "Foo", type: "char", searchable: true}, + bar: {string: "Bar", type: "boolean", searchable: true}, + product_id: {string: "Product", type: "many2one", relation: "product", searchable: true}, + }, + records: [{ + id: 1, + foo: "yop", + bar: true, + product_id: 37, + }, { + id: 2, + foo: "blip", + bar: true, + product_id: false, + }, { + id: 4, + foo: "abc", + bar: false, + product_id: 41, + }], + onchanges: {}, + }, + product: { + fields: { + name: {string: "Product Name", type: "char", searchable: true} + }, + records: [{ + id: 37, + display_name: "xphone", + }, { + id: 41, + display_name: "xpad", + }] + }, + }; + }, +}, function () { + + QUnit.test("creating a field chain from scratch", function (assert) { + assert.expect(10); + + var $target = $("#qunit-fixture"); + + // Create the field selector and its mock environment + var fieldSelector = new ModelFieldSelector(null, "partner", [], { + readonly: false, + debugMode: true, + }); + testUtils.addMockEnvironment(fieldSelector, {data: this.data}); + fieldSelector.appendTo($target); + var $value = fieldSelector.$("> .o_field_selector_value"); + + // Focusing the field selector input should open a field selector popover + fieldSelector.$el.trigger('focusin'); + var $fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible"); + assert.strictEqual($fieldSelectorPopover.length, 1, + "field selector popover should be visible"); + + // The field selector popover should contain the list of "partner" + // fields. "Bar" should be among them. + var $lis = $fieldSelectorPopover.find("li"); + var $barLi = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Bar") >= 0) { + $barLi = $li; + } + }); + assert.strictEqual($barLi.length, 1, + "field selector popover should contain the 'Bar' field"); + + // Clicking the "Bar" field should close the popover and set the field + // chain to "bar" as it is a basic field + $barLi.click(); + assert.notOk($fieldSelectorPopover.is("visible"), + "field selector popover should be closed now"); + assert.strictEqual(getValueFromDOM($value), "Bar", + "field selector value should be displayed with a 'Bar' tag"); + + // Focusing the input again should open the same popover + fieldSelector.$el.trigger('focusin'); + assert.ok($fieldSelectorPopover.is(":visible"), + "field selector popover should be visible"); + + // The field selector popover should contain the list of "partner" + // fields. "Product" should be among them. + $lis = $fieldSelectorPopover.find("li"); + var $productLi = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Product") >= 0) { + $productLi = $li; + } + }); + assert.strictEqual($productLi.length, 1, + "field selector popover should contain the 'Product' field"); + + // Clicking on the "Product" field should update the popover to show + // the product fields (so only "Product Name" should be there) + $productLi.click(); + $lis = $fieldSelectorPopover.find("li"); + assert.strictEqual($lis.length, 1, + "there should be only one field proposition for 'product' model"); + assert.ok($lis.first().html().indexOf("Product Name") >= 0, + "the name of the only suggestion should be 'Product Name'"); + + // Clicking on "Product Name" should close the popover and set the chain + // to "product_id.name" + $lis.first().click(); + assert.notOk($fieldSelectorPopover.is("visible"), + "field selector popover should be closed now"); + assert.strictEqual(getValueFromDOM($value), "Product -> Product Name", + "field selector value should be displayed with two tags: 'Product' and 'Product Name'"); + fieldSelector.destroy(); + + function getValueFromDOM($dom) { + return _.map($dom.find(".o_field_selector_chain_part"), function (part) { + return $(part).text().trim(); + }).join(" -> "); + } + }); +}); +}); +}); |
