diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 5e669e0aa57..5f43b04f7cd 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -21,6 +21,8 @@ var FormController = BasicController.extend({ toggle_column_order: '_onToggleColumnOrder', focus_control_button: '_onFocusControlButton', form_dialog_discarded: '_onFormDialogDiscarded', + swipe_left: '_onSwipeLeft', + swipe_right: '_onSwipeRight', }), /** * @override @@ -644,6 +646,30 @@ var FormController = BasicController.extend({ self._enableButtons(); }); }, + /** + * Called when user swipes left. Move to next record. + * + * @private + * @param {OdooEvent} ev + */ + _onSwipeLeft: function (ev) { + ev.stopPropagation(); + if (this.pager) { + this.pager.next(); + } + }, + /** + * Called when user swipes right. Move to previous record. + * + * @private + * @param {OdooEvent} ev + */ + _onSwipeRight: function (ev) { + ev.stopPropagation(); + if (this.pager) { + this.pager.previous(); + } + }, /** * This method is called when someone tries to sort a column, most likely * in a x2many list view diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index 6e9952c81d7..ac8ff541544 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -86,7 +86,7 @@ var FormRenderer = BasicRenderer.extend({ * @param {string} recordID * @returns {string[]} */ - canBeSaved: function (recordID) { + canBeSaved: function () { var self = this; var fieldNames = this._super.apply(this, arguments); @@ -130,7 +130,7 @@ var FormRenderer = BasicRenderer.extend({ * * @override */ - confirmChange: function (state, id, fields, e) { + confirmChange: function () { var self = this; return this._super.apply(this, arguments).then(function (resetWidgets) { _.each(resetWidgets, function (widget) { @@ -288,6 +288,30 @@ var FormRenderer = BasicRenderer.extend({ }); }); }, + /** + * Enable swipe event to allow navigating through records + * + * @private + */ + _enableSwipe: function () { + var self = this; + this.$('.o_form_sheet').swipe({ + swipeLeft: function () { + this.css({ + transform: 'translateX(-100%)', + transition: '350ms' + }); + self.trigger_up('swipe_left'); + }, + swipeRight: function () { + this.css({ + transform: 'translateX(100%)', + transition: '350ms' + }); + self.trigger_up('swipe_right'); + }, + }); + }, /** * @private * @param {string} name @@ -895,6 +919,11 @@ var FormRenderer = BasicRenderer.extend({ this.$el.toggleClass('o_form_editable', this.mode === 'edit'); this.$el.toggleClass('o_form_readonly', this.mode === 'readonly'); + // Enable swipe for mobile when formview is in readonly mode and there are multiple records + if (config.device.isMobile && this.mode === 'readonly' && this.state.count > 1) { + this._enableSwipe(); + } + // Attach the tooltips on the fields' label _.each(this.allFieldWidgets[this.state.id], function (widget) { var idForLabel = self.idsForLabels[widget.name]; diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js index c688a997581..01474a01180 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -2,6 +2,7 @@ odoo.define('web.FormView', function (require) { "use strict"; var BasicView = require('web.BasicView'); +var config = require('web.config'); var Context = require('web.Context'); var core = require('web.core'); var FormController = require('web.FormController'); @@ -18,6 +19,7 @@ var FormView = BasicView.extend({ icon: 'fa-edit', multi_record: false, searchable: false, + jsLibs: [], viewType: 'form', /** * @override @@ -41,6 +43,9 @@ var FormView = BasicView.extend({ this.controllerParams.mode = mode; this.rendererParams.mode = mode; + if (config.device.isMobile) { + this.jsLibs.push('/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js'); + } }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/tests/fields/basic_fields_mobile_tests.js b/addons/web/static/tests/fields/basic_fields_mobile_tests.js new file mode 100644 index 00000000000..76482a71e83 --- /dev/null +++ b/addons/web/static/tests/fields/basic_fields_mobile_tests.js @@ -0,0 +1,178 @@ +odoo.define('web.basic_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('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, trim: 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}, + }, + 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, + }, { + id: 2, + display_name: "second record", + bar: true, + foo: "blip", + int_field: 0, + qux: 0, + }, { + id: 4, + display_name: "aaa", + foo: "abc", + int_field: false, + qux: false, + }], + onchanges: {}, + }, + }; + } +}, function () { + + 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:'
' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + }); + + var $phoneLink = form.$('a.o_form_uri.o_field_widget'); + assert.strictEqual($phoneLink.length, 1, + "should have a anchor with correct classes"); + assert.strictEqual($phoneLink.text(), 'yop', + "value should be displayed properly"); + 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_field_widget').length, 1, + "should have an int for the phone field"); + assert.strictEqual(form.$('input[type="text"].o_field_widget').val(), 'yop', + "input should contain field value in edit mode"); + + // change value in edit mode + form.$('input[type="text"].o_field_widget').val('new').trigger('input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + $phoneLink = form.$('a.o_form_uri.o_field_widget'); + assert.strictEqual($phoneLink.text(), 'new', + "new value should be displayed properly"); + 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: '', + }); + + 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"); + + var $phoneLink = list.$('a.o_form_uri.o_field_widget'); + 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.parent().hasClass('o_selected_row'), '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.parent().hasClass('o_selected_row'), '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"); + $phoneLink = list.$('a.o_form_uri.o_field_widget'); + 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 does not allow html injections', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + var val = ''; + form.$('input').val(val).trigger('input'); + + // save + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('.o_field_widget').text(), val, + "value should have been correctly escaped"); + + form.destroy(); + }); +}); +}); +}); diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 68643f7b48d..4036594609f 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -3269,7 +3269,6 @@ QUnit.module('basic_fields', { list.destroy(); }); - QUnit.module('FieldFloatTime'); QUnit.test('float_time field in form view', function (assert) { @@ -3400,106 +3399,6 @@ QUnit.module('basic_fields', { 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:'
' + - '' + - '' + - '' + - '' + - '' + - '
', - res_id: 1, - config: { - device: { - size_class: config.device.SIZES.VSM, - }, - }, - }); - - var $phoneLink = form.$('a.o_form_uri.o_field_widget'); - assert.strictEqual($phoneLink.length, 1, - "should have a anchor with correct classes"); - assert.strictEqual($phoneLink.text(), 'yop', - "value should be displayed properly"); - 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_field_widget').length, 1, - "should have an input for the phone field"); - assert.strictEqual(form.$('input[type="text"].o_field_widget').val(), 'yop', - "input should contain field value in edit mode"); - - // change value in edit mode - form.$('input[type="text"].o_field_widget').val('new').trigger('input'); - - // save - form.$buttons.find('.o_form_button_save').click(); - $phoneLink = form.$('a.o_form_uri.o_field_widget'); - assert.strictEqual($phoneLink.text(), 'new', - "new value should be displayed properly"); - 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: '', - config: { - device: { - size_class: config.device.SIZES.VSM, - }, - }, - }); - - 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"); - - var $phoneLink = list.$('a.o_form_uri.o_field_widget'); - 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.parent().hasClass('o_selected_row'), '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.parent().hasClass('o_selected_row'), '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"); - $phoneLink = list.$('a.o_form_uri.o_field_widget'); - 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) { assert.expect(5); @@ -3517,7 +3416,7 @@ QUnit.module('basic_fields', { res_id: 1, config: { device: { - size_class: config.device.SIZES.MD, + size_class: config.device.SIZES.LG, }, }, }); @@ -3556,7 +3455,7 @@ QUnit.module('basic_fields', { arch: '', config: { device: { - size_class: config.device.SIZES.MD, + size_class: config.device.SIZES.LG, }, }, }); @@ -3589,42 +3488,6 @@ QUnit.module('basic_fields', { list.destroy(); }); - QUnit.test('phone field does not allow html injections', function (assert) { - assert.expect(1); - - var form = createView({ - View: FormView, - model: 'partner', - data: this.data, - arch:'
' + - '' + - '' + - '' + - '' + - '' + - '
', - res_id: 1, - viewOptions: { - mode: 'edit', - }, - config: { - device: { - size_class: config.device.SIZES.VSM, - }, - }, - }); - - var val = ''; - form.$('input').val(val).trigger('input'); - - // save - form.$buttons.find('.o_form_button_save').click(); - assert.strictEqual(form.$('.o_field_widget').text(), val, - "value should have been correctly escaped"); - - form.destroy(); - }); - QUnit.test('use TAB to navigate to a phone field', function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/fields/relational_fields_mobile_tests.js b/addons/web/static/tests/fields/relational_fields_mobile_tests.js new file mode 100644 index 00000000000..8a1ab7ce1a5 --- /dev/null +++ b/addons/web/static/tests/fields/relational_fields_mobile_tests.js @@ -0,0 +1,119 @@ +odoo.define('web.relational_fields_tests', function (require) { +"use strict"; + +var FormView = require('web.FormView'); +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'}, + trululu: {string: "Trululu", type: "many2one", relation: 'partner'}, + }, + records: [{ + id: 1, + display_name: "first record", + bar: true, + foo: "yop", + int_field: 10, + qux: 0.44, + p: [], + trululu: 4, + }, { + id: 2, + display_name: "second record", + bar: true, + foo: "blip", + int_field: 9, + qux: 13, + p: [], + trululu: 1, + }, { + id: 4, + display_name: "aaa", + bar: false, + }], + onchanges: {}, + }, + }; + } +}, function () { + + QUnit.module('FieldOne2Many'); + + QUnit.test('one2many kanban: deletion in mobile', function (assert) { + assert.expect(9); + + this.data.partner.records[0].p = [1, 2]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '', + 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] === 2, + 'should have generated the command 2 (DELETE) with id 1'); + assert.ok(commands[1][0] === 2 && commands[1][1] === 1, + 'should have generated the command 2 (DELETE) with id 2'); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_field_one2many .o-kanban-button-new').length, 0, + '"Create" button should not be visible in readonly'); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_field_one2many .o-kanban-button-new').length, 1, + '"Create" button should be visible in edit'); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2, + "should have 2 records"); + + // open and delete record + form.$('.oe_kanban_global_click').first().click(); + assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1, + 'there should be a Remove button in the modal footer'); + $('.modal .modal-footer .o_btn_remove').click(); + assert.strictEqual($('.o_modal').length, 0, "there shoul be no more modal"); + assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1, + 'should contain 1 records'); + + // save and check that the correct command has been generated + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); +}); +}); +}); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index bb7b4a446e6..245ff8f7a07 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -4909,74 +4909,6 @@ QUnit.module('relational_fields', { form.destroy(); }); - QUnit.test('one2many kanban: deletion in mobile', function (assert) { - assert.expect(9); - - this.data.partner.records[0].p = [1, 2]; - var form = createView({ - View: FormView, - model: 'partner', - data: this.data, - arch: '
' + - '' + - '' + - '' + - '' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '' + - '' + - '', - 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] === 2, - 'should have generated the command 2 (DELETE) with id 1'); - assert.ok(commands[1][0] === 2 && commands[1][1] === 1, - 'should have generated the command 2 (DELETE) with id 2'); - } - return this._super.apply(this, arguments); - }, - config: { - device: { - size_class: config.device.SIZES.XS, - }, - }, - }); - - assert.strictEqual(form.$('.o_field_one2many .o-kanban-button-new').length, 0, - '"Create" button should not be visible in readonly'); - - form.$buttons.find('.o_form_button_edit').click(); - - assert.strictEqual(form.$('.o_field_one2many .o-kanban-button-new').length, 1, - '"Create" button should be visible in edit'); - assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2, - "should have 2 records"); - - // open and delete record - form.$('.oe_kanban_global_click').first().click(); - assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1, - 'there should be a Remove button in the modal footer'); - $('.modal .modal-footer .o_btn_remove').click(); - assert.strictEqual($('.o_modal').length, 0, "there shoul be no more modal"); - assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1, - 'should contain 1 records'); - - // save and check that the correct command has been generated - form.$buttons.find('.o_form_button_save').click(); - form.destroy(); - }); - QUnit.test('one2many kanban: create action disabled', function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/views/form_mobile_tests.js b/addons/web/static/tests/views/form_mobile_tests.js new file mode 100644 index 00000000000..05a19a27668 --- /dev/null +++ b/addons/web/static/tests/views/form_mobile_tests.js @@ -0,0 +1,92 @@ +odoo.define('web.form_mobile_tests', function (require) { +"use strict"; + +var FormView = require('web.FormView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +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"}, + }, + records: [{ + id: 1, + display_name: "first record", + bar: true, + foo: "yop", + }, { + id: 2, + display_name: "second record", + bar: true, + foo: "blip", + }, { + id: 4, + display_name: "aaa", + state: "ef", + }, { + id: 5, + display_name: "aaa", + foo:'', + bar: false, + }], + onchanges: {}, + }, + }; + } +}, function () { + + QUnit.module('FormView'); + + QUnit.test('switching to next/previous record on swipe in readonly mode', function (assert) { + assert.expect(6); + + // mimic touchSwipe library's swipe method + var oldSwipe = $.fn.swipe; + var swipeLeft, swipeRight; + $.fn.swipe = function (params) { + swipeLeft = params.swipeLeft.bind(this); + swipeRight = params.swipeRight.bind(this); + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '
', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/partner/read') { + assert.step(args.args[0][0]); + } + return this._super(route, args); + }, + }); + + swipeLeft(); + assert.strictEqual(form.pager.$('.o_pager_value').text(), '2', 'pager value should be 2'); + + swipeRight(); + assert.strictEqual(form.pager.$('.o_pager_value').text(), '1', 'pager value should be 1'); + + assert.verifySteps([1, 2, 1]); + + $.fn.swipe = oldSwipe; + form.destroy(); + }); +}); + +}); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 219972f46c9..222317ae3e4 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -7036,7 +7036,6 @@ QUnit.module('Views', { form.destroy(); }); - QUnit.module('FormViewTABMainButtons'); QUnit.test('using tab in an empty required string field should not move to the next field',function(assert) { diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index a33592a602a..975d5fc63ec 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -625,6 +625,9 @@ + + +