diff options
| author | Christophe Simonis <chs@odoo.com> | 2017-10-21 15:22:36 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2017-10-21 15:22:36 +0200 |
| commit | de987831da237ed5f9c96d50a9e51d083e072aa0 (patch) | |
| tree | ef714d2d98693eae3446620d28bb48314573018a /addons/web | |
| parent | ea2a976f31b2611b6b4ff69f4a5cdc2ffc7798fe (diff) | |
| parent | d70518d04f8f8061c8c683ea4ba8c95ae631ae45 (diff) | |
[MERGE] forward port branch 11.0 up to d70518d04f8
Diffstat (limited to 'addons/web')
18 files changed, 474 insertions, 58 deletions
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 17ca031a48f..67db357ec05 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -18,13 +18,14 @@ import os import re import sys import time +import zlib + import werkzeug import werkzeug.utils import werkzeug.wrappers -import zlib +import werkzeug.wsgi +from werkzeug.urls import url_decode, iri_to_uri from xml.etree import ElementTree -from werkzeug import url_decode -from werkzeug import iri_to_uri import odoo @@ -33,11 +34,11 @@ from odoo.api import call_kw, Environment from odoo.modules import get_resource_path from odoo.tools import crop_image, topological_sort, html_escape, pycompat from odoo.tools.translate import _ -from odoo.tools.misc import str2bool, xlwt +from odoo.tools.misc import str2bool, xlwt, file_open from odoo.tools.safe_eval import safe_eval from odoo import http from odoo.http import content_disposition, dispatch_rpc, request, \ - serialize_exception as _serialize_exception + serialize_exception as _serialize_exception, Response from odoo.exceptions import AccessError, UserError from odoo.models import check_method_name @@ -513,21 +514,24 @@ class WebClient(http.Controller): @http.route('/web/webclient/locale/<string:lang>', type='http', auth="none") def load_locale(self, lang): magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]] - addons_path = http.addons_manifest['web']['addons_path'] - # load momentjs locale - momentjs_locale = "" for code in magic_file_finding: try: - with open(os.path.join(addons_path, 'web', 'static', 'lib', 'moment', 'locale', code + '.js'), 'r') as f: - momentjs_locale = f.read() - # we found a locale matching so we can exit - break + return http.Response( + werkzeug.wsgi.wrap_file( + request.httprequest.environ, + file_open('web/static/lib/moment/locale/%s.js' % code, 'rb') + ), + content_type='application/javascript; charset=utf-8', + headers=[('Cache-Control', 'max-age=36000')], + direct_passthrough=True, + ) except IOError: - continue + _logger.debug("No moment locale for code %s", code) - # return the content of the locale - headers = [('Content-Type', 'application/javascript'), ('Cache-Control', 'max-age=%s' % (36000))] - return request.make_response(momentjs_locale, headers) + return request.make_response("", headers=[ + ('Content-Type', 'application/javascript'), + ('Cache-Control', 'max-age=36000'), + ]) @http.route('/web/webclient/qweb', type='http', auth="none", cors="*") def qweb(self, mods=None, db=None): diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index d69574d2e10..0ee6d2ad1ed 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -212,6 +212,9 @@ var loadJS = (function () { * @param {Function} [options.success] callback in case of download success * @param {Function} [options.error] callback in case of request error, provided with the error body * @param {Function} [options.complete] called after both ``success`` and ``error`` callbacks have executed + * @returns {boolean} a false value means that a popup window was blocked. This + * mean that we probably need to inform the user that something needs to be + * changed to make it work. */ function get_file(options) { // need to detect when the file is done downloading (not used @@ -232,7 +235,12 @@ function get_file(options) { var url = options.session.url(options.url, params); if (options.complete) { options.complete(); } - return window.open(url); + var w = window.open(url); + if (!w || w.closed || typeof w.closed === 'undefined') { + // popup was blocked + return false; + } + return true; } var $form, $form_data = $('<div>'); @@ -309,6 +317,7 @@ function get_file(options) { } }; timer = setTimeout(waitLoop, CHECK_INTERVAL); + return true; }; function post (controller_url, data) { diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js index 64ebeeb1794..983b26d3606 100644 --- a/addons/web/static/src/js/core/misc.js +++ b/addons/web/static/src/js/core/misc.js @@ -173,6 +173,23 @@ function ReloadContext (parent, action) { } core.action_registry.add("reload_context", ReloadContext); +// In Internet Explorer, document doesn't have the contains method, so we make a +// polyfill for the method in order to be compatible. +if (!document.contains) { + document.contains = function contains (node) { + if (!(0 in arguments)) { + throw new TypeError('1 argument is required'); + } + + do { + if (this === node) { + return true; + } + } while (node = node && node.parentNode); + + return false; + }; +} return { blockUI: blockUI, diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js index 0ecea92fb71..c3181a7abe6 100644 --- a/addons/web/static/src/js/core/session.js +++ b/addons/web/static/src/js/core/session.js @@ -278,7 +278,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { options.data.session_id = this.session_id; } options.session = this; - ajax.get_file(options); + return ajax.get_file(options); }, /** * (re)loads the content of a session: db name, username, user id, session diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 6392870dc90..8eae8f5b8f1 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -394,7 +394,7 @@ var LinkButton = AbstractField.extend({ var FieldDate = InputField.extend({ className: "o_field_date", tagName: "span", - supportedFieldTypes: ['date'], + supportedFieldTypes: ['date', 'datetime'], /** * @override @@ -453,12 +453,13 @@ var FieldDate = InputField.extend({ * @override * @private * @param {Moment|false} value + * @returns {boolean} */ _isSameValue: function (value) { if (value === false) { return this.value === false; } - return value.isSame(this.value); + return value.isSame(this.value, 'day'); }, /** * Instantiates a new DateWidget datepicker. @@ -497,7 +498,16 @@ var FieldDateTime = FieldDate.extend({ var value = this.datewidget.getValue(); return value && value.add(-this.getSession().getTZOffset(value), 'minutes'); }, - + /** + * @override + * @private + */ + _isSameValue: function (value) { + if (value === false) { + return this.value === false; + } + return value.isSame(this.value); + }, /** * Instantiates a new DateTimeWidget datepicker rather than DateWidget. * @@ -966,7 +976,7 @@ var FieldPhone = FieldEmail.extend({ * @override */ getFocusableElement: function () { - if (this._canCall()) { + if (this.mode !== 'readonly' || this._canCall()) { return this._super.apply(this, arguments); } return $(); @@ -1171,7 +1181,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({ supportedFieldTypes: ['binary'], _render: function () { var self = this; - var attrs = this.attrs; var url = this.placeholder; if (this.value) { if (!utils.is_bin_size(this.value)) { @@ -1186,11 +1195,7 @@ var FieldBinaryImage = AbstractFieldBinary.extend({ }); } } - var $img = $('<img>').attr('src', url); - $img.css({ - width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width, - height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height, - }); + var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url})); this.$('> img').remove(); this.$el.prepend($img); $img.on('error', function () { @@ -2175,6 +2180,15 @@ var FieldDomain = AbstractField.extend({ //-------------------------------------------------------------------------- /** + * A domain field is always set since the false value is considered to be + * equal to "[]" (match all records). + * + * @override + */ + isSet: function () { + return true; + }, + /** * @override isValid from AbstractField.isValid * Parsing the char value is not enough for this field. It is considered * valid if the internal domain selector was built correctly and that the diff --git a/addons/web/static/src/js/report/qwebactionmanager.js b/addons/web/static/src/js/report/qwebactionmanager.js index 3c8fee859e3..cc875882ce2 100644 --- a/addons/web/static/src/js/report/qwebactionmanager.js +++ b/addons/web/static/src/js/report/qwebactionmanager.js @@ -21,7 +21,7 @@ var WKHTMLTOPDF_MESSAGES = { }; var trigger_download = function (session, response, c, action, options) { - session.get_file({ + return session.get_file({ url: '/report/download', data: {data: JSON.stringify(response)}, complete: framework.unblockUI, @@ -107,7 +107,10 @@ ActionManager.include({ report_urls['qweb-pdf'], action.report_type, //The 'root' report is considered the maine one, so we use its type for all the others. ]; - trigger_download(self.getSession(), response, c, current_action, options); + var success = trigger_download(self.getSession(), response, c, current_action, options); + if (!success) { + self.do_warn(_t('Warning'), _t('A popup window with your report was blocked. You may need to change your browser settings to allow popup windows for this page.'), true); + } treated_actions.push(current_action); current_action = current_action.next_report_to_generate; diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 6884442039e..1df118ecfd9 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -875,7 +875,10 @@ var BasicModel = AbstractModel.extend({ // id never changes, and should not be written delete record._changes.id; } - var changes = self._generateChanges(record, {viewType: options.viewType}); + var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'}); + + // id field should never be written/changed + delete changes.id; if (method === 'create') { var fieldNames = record.getFieldNames(); @@ -1551,6 +1554,9 @@ var BasicModel = AbstractModel.extend({ removedDef = this._applyX2ManyChange(record, fieldName, { operation: 'DELETE', ids: _.map(removedIds, function (resID) { + if (resID in list._cache) { + return list._cache[resID]; + } return _.findWhere(listData, {res_id: resID}).id; }), }); @@ -2455,7 +2461,7 @@ var BasicModel = AbstractModel.extend({ changes = _.extend({}, record.data, record._changes); } else { changes = _.extend({}, record._changes); - }; + } var withReadonly = options.withReadonly || false; var commands = this._generateX2ManyCommands(record, { changesOnly: 'changesOnly' in options ? options.changesOnly : true, @@ -2492,6 +2498,7 @@ var BasicModel = AbstractModel.extend({ changes[fieldName] = false; } } + return changes; }, /** @@ -2608,6 +2615,7 @@ var BasicModel = AbstractModel.extend({ _.each(relRecordUpdated, function (relRecord) { var changes = self._generateChanges(relRecord, options); if (!_.isEmpty(changes)) { + delete changes.id; var command = x2ManyCommands.update(relRecord.res_id, changes); commands[fieldName].push(command); } @@ -2638,7 +2646,12 @@ var BasicModel = AbstractModel.extend({ // this is a new id relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); changes = this._generateChanges(relRecord, options); - commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + if ('id' in changes) { + delete changes.id; + commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes)); + } else { + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + } } } if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { @@ -3294,11 +3307,17 @@ var BasicModel = AbstractModel.extend({ * This method is quite important: it is supposed to perform the /onchange * rpc and apply the result. * + * The changes that triggered the onchange are assumed to have already been + * applied to the record. + * * @param {Object} record * @param {string[]} fields changed fields * @param {string} [viewType] current viewType. If not set, we will assume * main viewType from the record - * @returns {Deferred} + * @returns {Deferred} Note: this deferred cannot fail. It is either in the + * 'success' or 'pending' state. If the onchange rpc fails, it will be + * resolved with an empty dictionary. Note that the initial change was + * already applied. */ _performOnChange: function (record, fields, viewType) { var self = this; @@ -3318,7 +3337,9 @@ var BasicModel = AbstractModel.extend({ var context = this._getContext(record, options); var currentData = this._generateOnChangeData(record, {changesOnly: false}); - return self._rpc({ + var def = $.Deferred(); + + self._rpc({ model: record.model, method: 'onchange', args: [idList, currentData, fields, onchangeSpec, context], @@ -3343,9 +3364,12 @@ var BasicModel = AbstractModel.extend({ record._domains = _.extend(record._domains, result.domain); } return self._applyOnChange(result.value, record).then(function () { - return result; + def.resolve(result); }); + }).fail(function () { + def.resolve({}); }); + return def; }, /** * Once a record is created and some data has been fetched, we need to do diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index 4168d901065..08d03c037f0 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -257,13 +257,20 @@ var CalendarController = AbstractController.extend({ id = id && parseInt(id).toString() === id ? parseInt(id) : id; if (!this.eventOpenPopup) { - this.do_action({ - type: 'ir.actions.act_window', - res_id: id, - res_model: this.modelName, - views: [[this.formViewId || false, 'form']], - target: 'current', - context: event.context || self.context, + this._rpc({ + model: self.modelName, + method: 'get_formview_id', + //The event can be called by a view that can have another context than the default one. + args: [[id], event.context || self.context], + }).then(function (viewId) { + self.do_action({ + type:'ir.actions.act_window', + res_id: id, + res_model: self.modelName, + views: [[viewId || false, 'form']], + target: 'current', + context: event.context || self.context, + }); }); return; } 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 b9711d1d392..0ddf2f0a129 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -807,7 +807,15 @@ var FormRenderer = BasicRenderer.extend({ // Attach the tooltips on the fields' label _.each(this.allFieldWidgets[this.state.id], function (widget) { var idForLabel = self.idsForLabels[widget.name]; + // We usually don't support multiple widgets for the same field on the + // same view but it is the case with the new settings view on V11.0. + // Therefore, we need to retrieve the correct label since it could be + // displayed multiple times on the view, otherwise, for example the + // enterprise label will be displayed as many times as the field + // exists on settings. + var $widgets = self.$('.o_field_widget[name=' + widget.name + ']'); var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); + $label = $label.eq($widgets.index(widget.$el)); if (config.debug || widget.attrs.help || widget.field.help) { self._addFieldTooltip(widget, $label); } diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 9027fc3c984..b0ddbdcd385 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -136,7 +136,9 @@ ListRenderer.include({ currentRowID = this.state.data[this.currentRow].id; currentWidget = this.allFieldWidgets[currentRowID][this.currentFieldIndex]; focusedElement = currentWidget.getFocusableElement().get(0); - selectionRange = dom.getSelectionRange(focusedElement); + if (currentWidget.formatType !== 'boolean') { + selectionRange = dom.getSelectionRange(focusedElement); + } } var oldData = this.state.data; @@ -179,7 +181,9 @@ ListRenderer.include({ currentRowID = self.state.data[newRowIndex].id; currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; focusedElement = currentWidget.getFocusableElement().get(0); - dom.setSelectionRange(focusedElement, selectionRange); + if (selectionRange) { + dom.setSelectionRange(focusedElement, selectionRange); + } }); } }); @@ -585,12 +589,18 @@ ListRenderer.include({ if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) { return $.Deferred().reject(); } + // _activateFieldWidget might trigger an onchange, + // which requires currentFieldIndex to be set + // so that the cursor can be restored + var oldFieldIndex = self.currentFieldIndex; + self.currentFieldIndex = fieldIndex; fieldIndex = self._activateFieldWidget(record, fieldIndex, { inc: 1, wrap: wrap, event: options && options.event, }); if (fieldIndex < 0) { + self.currentFieldIndex = oldFieldIndex; return $.Deferred().reject(); } self.currentFieldIndex = fieldIndex; diff --git a/addons/web/static/src/less/modal.less b/addons/web/static/src/less/modal.less index 1d95ddc219b..95a14420773 100644 --- a/addons/web/static/src/less/modal.less +++ b/addons/web/static/src/less/modal.less @@ -52,6 +52,8 @@ .modal-body { overflow: auto; + // fix iOS issue https://github.com/scottjehl/Device-Bugs/issues/8 + -webkit-transform: translate3d(0, 0, 0); min-height: 0; &.o_dialog_error { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index e1f955370d3..7da3f448378 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -335,12 +335,12 @@ <t t-if="item.create_uid and item.create_uid[0]" t-set="item.title"> <t t-raw="item.title"/><br/> <b>Created by : </b><br/> - <t t-raw="item.create_uid[1] + ' ' + item.create_date"/> + <t t-raw="item.create_uid[1]"/> <t t-esc="item.create_date_string" /> </t> <t t-if="item.create_uid and item.write_uid and item.create_uid[0] != item.write_uid[0]" t-set="item.title"> <t t-raw="item.title"/><br/> <b>Modified by : </b><br/> - <t t-raw="item.write_uid[1] + ' ' + item.write_date"/> + <t t-raw="item.write_uid[1]"/> <t t-esc="item.write_date_string"/> </t> </t> <a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url"> @@ -861,8 +861,8 @@ t-att-src='url' t-att-border="widget.readonly ? 0 : 1" t-att-name="widget.name" - t-att-width="widget.node.attrs.img_width || widget.node.attrs.width" - t-att-height="widget.node.attrs.img_height || widget.node.attrs.height"/> + t-att-width="widget.nodeOptions.size ? widget.nodeOptions.size[0] : widget.attrs.img_width || widget.attrs.width" + t-att-height="widget.nodeOptions.size ? widget.nodeOptions.size[1] : widget.attrs.img_height || widget.attrs.height"/> </t> <t t-name="FieldBinaryFile"> <a t-if="widget.mode === 'readonly'" href="javascript:void(0)" class="o_form_uri"/> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index f01f2d8b589..1e623e54d9a 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1462,6 +1462,42 @@ QUnit.module('basic_fields', { } }); + QUnit.module('FieldImage'); + + QUnit.test('image fields are correctly rendered', function (assert) { + assert.expect(5); + + this.data.partner.records[0].document = 'myimage'; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="document" widget="image" options="{\'size\': [90, 90]}"/> ' + + '</form>', + res_id: 1, + mockRPC: function (route) { + if (route === 'data:image/png;base64,myimage') { + assert.ok(true, "should called the correct route"); + return $.when('wow'); + } + return this._super.apply(this, arguments); + }, + }); + + assert.ok(form.$('div[name="document"]').hasClass('o_field_image'), + "the widget should have the correct class"); + assert.strictEqual(form.$('div[name="document"] > img').length, 1, + "the widget should contain an image"); + assert.ok(form.$('div[name="document"] > img').hasClass('img-responsive'), + "the image should have the correct class"); + assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90", + "the image should correctly set its attributes"); + form.destroy(); + + }); + QUnit.module('JournalDashboardGraph'); QUnit.test('graph dashboard widget is rendered correctly', function (assert) { @@ -1844,6 +1880,39 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('do not trigger a field_changed for datetime field with date widget', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="datetime" widget="date"/></form>', + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_datepicker_input').val(), '02/08/2017', + 'the date should be correct'); + + form.$('input[name="datetime"]').val('02/08/2017').trigger('input').trigger('change'); + form.$buttons.find('.o_form_button_save').click(); + + assert.verifySteps(['read']); // should not have save as nothing changed + + form.destroy(); + }); + QUnit.module('FieldDatetime'); QUnit.test('datetime field in form view', function (assert) { @@ -2828,6 +2897,33 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('use TAB to navigate to a phone field', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="display_name"/>' + + '<field name="foo" widget="phone"/>' + + '</group>' + + '</sheet>' + + '</form>', + }); + + form.$('input[name=display_name]').click(); + assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement, + "display_name should be focused"); + form.$('input[name="display_name"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, + "foo should be focused"); + + form.destroy(); + }); + QUnit.module('PriorityWidget'); QUnit.test('priority widget when not set', function (assert) { @@ -3939,7 +4035,7 @@ QUnit.module('basic_fields', { }); QUnit.test('domain field: handle false domain as []', function (assert) { - assert.expect(2); + assert.expect(3); this.data.partner.records[0].foo = false; this.data.partner.fields.bar.type = "char"; @@ -3965,11 +4061,13 @@ QUnit.module('basic_fields', { return this._super.apply(this, arguments); }, res_id: 1, - viewOptions: { - mode: 'edit', - }, }); + assert.strictEqual(form.$('.o_field_widget[name=foo]:not(.o_field_empty)').length, 1, + "there should be a domain field, not considered empty"); + + form.$buttons.find('.o_form_button_edit').click(); + var $warning = form.$('.o_field_widget[name=foo] .text-warning'); assert.strictEqual($warning.length, 0, "should not display that the domain is invalid"); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 641546b3fc1..2d328f0b7ff 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1064,14 +1064,14 @@ QUnit.module('relational_fields', { }); QUnit.test('list in form: default_get with x2many create and onchange', function (assert) { - assert.expect(1); + assert.expect(2); this.data.partner.onchanges.turtles = function (obj) { assert.deepEqual( obj.turtles, [ - [0, 2, {turtle_foo: 'blip', id: 2}], - [0, 3, {turtle_foo: 'kawa', id: 3}] + [1, 2, {turtle_foo: 'blip'}], + [1, 3, {turtle_foo: 'kawa'}] ], "should have properly created the x2many command list"); }; @@ -1094,10 +1094,21 @@ QUnit.module('relational_fields', { if (args.method === 'default_get') { return $.when({turtles: [[6, 0, [2,3]]]}); } + if (args.method === 'create') { + // it would be even better if we did not send the current + // unchanged state with the command 1, but this seems more + // difficult. + assert.deepEqual(args.args[0].turtles, [ + [1, 2, {turtle_foo: 'blip'}], + [1, 3, {turtle_foo: 'kawa'}] + ], 'should send proper commands to create method'); + } return this._super.apply(this, arguments); }, }); + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); }); @@ -6248,6 +6259,35 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('checkbox in an x2many that triggers an onchange', 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="p">' + + '<tree editable="bottom">' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</form>', + }); + + form.$('.o_field_x2many_list_row_add a').click(); + + form.$('.o_field_widget[name=bar] input').click(); + assert.notOk(form.$('.o_field_widget[name=bar] input').prop('checked'), + "the checkbox should be unticked"); + + form.destroy(); + }); + QUnit.test('one2many with default value: edit line to make it invalid', function (assert) { assert.expect(3); @@ -6284,6 +6324,66 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('display correct value after validation error', function (assert) { + assert.expect(4); + + this.data.partner.onchanges.turtles = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + // we simulate a validation error. In the 'real' web client, + // the server error will be used by the session to display + // an error dialog. From the point of view of the basic + // model, the deferred is just rejected. + return $.Deferred().reject(); + } + if (args.method === 'write') { + assert.deepEqual(args.args[1].turtles[0], [1,2,{turtle_foo: 'foo'}], + 'should send the "bad" value'); + // we simulate a validation error + return $.Deferred().reject(); + } + return this._super.apply(this, arguments); + }, + viewOptions: {mode: 'edit'}, + res_id: 1, + }); + + assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'blip', + "initial text should be correct"); + form.$('.o_data_row .o_data_cell:nth(0)').click(); + form.$('.o_field_widget[name=turtle_foo]').val('foo').trigger('input'); + + // we try to validate the line. This triggers an onchange which will be + // rejected. The line will be returned to readonly mode, but with the + // new invalid value. + form.$el.click(); + + assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo', + "turtle_foo text should now be foo (invalid value)"); + + // we make sure here that when we save, the values are the current + // values displayed in the field. + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.mode, 'edit', "form view should still be in edit mode"); + + form.destroy(); + }); + + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { @@ -7128,6 +7228,42 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('default_get, onchange, onchange on m2m', function (assert) { + assert.expect(1); + + this.data.partner.onchanges.int_field = function (obj) { + if (obj.int_field === 2) { + assert.deepEqual(obj.timmy, [ + [6, false, [12]], + [1, 12, {display_name: 'gold'}] + ]); + } + obj.timmy = [ + [5], + [1, 12, {display_name: 'gold'}] + ]; + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="timmy">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '<field name="int_field"/>' + + '</sheet>' + + '</form>', + }); + + form.$('.o_field_widget[name=int_field]').val(2).trigger('input'); + form.destroy(); + }); + QUnit.module('FieldStatus'); QUnit.test('static statusbar widget on many2one field', function (assert) { @@ -8288,6 +8424,32 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('widget many2many_checkboxes: start non empty, then remove twice', 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">' + + '<group><field name="timmy" widget="many2many_checkboxes"/></group>' + + '</form>', + res_id: 1, + viewOptions: {mode: 'edit'}, + }); + + form.$('div.o_field_widget div.o_checkbox input').eq(0).click(); + form.$('div.o_field_widget div.o_checkbox input').eq(1).click(); + form.$buttons.find('.o_form_button_save').click(); + assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(0).prop('checked'), + "first checkbox should not be checked"); + assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(1).prop('checked'), + "second checkbox should not be checked"); + + form.destroy(); + }); + QUnit.module('FieldMany2ManyBinaryMultiFiles'); QUnit.test('widget many2many_binary', function (assert) { diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 8cb30291f24..dfcbdba5985 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -110,6 +110,14 @@ var MockServer = Class.extend({ console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString)); } return JSON.parse(resultString); + }).fail(function (result) { + var errorString = JSON.stringify(result || false); + if (logLevel === 1) { + console.log('Mock: (ERROR)' + route, JSON.parse(errorString)); + } else if (logLevel === 2) { + console.log('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString)); + } + return JSON.parse(errorString); }); }, diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index ecf02be9c4a..ed9e416a302 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -975,6 +975,12 @@ QUnit.module('Views', { viewOptions: { initialDate: initialDate, }, + mockRPC: function (route, args) { + if (args.method === "get_formview_id") { + return $.Deferred().resolve('A view'); + } + return this._super(route, args); + }, }); // click on an existing event to open the form view @@ -985,7 +991,7 @@ QUnit.module('Views', { type: "ir.actions.act_window", res_id: 4, res_model: "event", - views: [[false, "form"]], + views: [['A view', "form"]], target: "current", context: {} }, @@ -1103,6 +1109,12 @@ QUnit.module('Views', { viewOptions: { initialDate: initialDate, }, + mockRPC: function (route, args) { + if (args.method === "get_formview_id") { + return $.when(false); + } + return this._super(route, args); + }, }); assert.strictEqual(calendar.$('.fc-resizer').length, 0, "should not have resize button"); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index ac45b6de256..989c67e78dd 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -2649,6 +2649,44 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('default_get, onchange which fails, should still work', function (assert) { + assert.expect(1); + + this.data.partner.onchanges.foo = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + // we simulate a validation error. In the 'real' web client, + // the server error will be used by the session to display + // an error dialog. From the point of view of the basic + // model, the deferred is just rejected. + return $.Deferred().reject(); + } + return this._super.apply(this, arguments); + }, + }); + + // this test checks that a form view is still rendered when the server + // onchange fails (for example, because of a validation error, or, more + // likely, a bug in the onchange code). This is quite rare, but if the + // onchange fails, we still want to display the form view (with the error + // dialog from the session/crashmanager). Otherwise, we could be in the + // situation where a user clicks on a button, it should open a wizard, + // but something fails and the wizard is not even rendered. In that + // case, there is nothing that the user could do. + assert.strictEqual(form.$('.o_field_widget[name="foo"]').val(), 'My little Foo Value', + "should display proper default value"); + + form.destroy(); + }); + QUnit.test('attrs are properly transmitted to new records', function (assert) { assert.expect(2); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index cdc29635cd1..9b222f8563d 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -252,7 +252,7 @@ <head> <meta charset="utf-8"/> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/> - <meta name="viewport" content="width=device-width, initial-scale=1"/> + <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/> <title t-esc="title or 'Odoo'"/> <link type="image/x-icon" rel="shortcut icon" t-att-href="x_icon or '/web/static/src/img/favicon.ico'"/> |
