diff options
| author | Christophe Simonis <chs@odoo.com> | 2017-06-09 17:11:47 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2017-06-09 17:11:47 +0200 |
| commit | ab02bf34b0c1d3fa3752ca1f50b098d04dc5b137 (patch) | |
| tree | a29591cae8777390cbeb35d65e23c9f8ae143135 /addons/web | |
| parent | b350c29e44b4ed1df0c428308c396771b22d73e8 (diff) | |
| parent | cc38b93ab2a6daf9c5badbc387cff20d1a01ddee (diff) | |
[MERGE] forward port branch saas-16 up to cc38b93ab2a
Diffstat (limited to 'addons/web')
23 files changed, 655 insertions, 87 deletions
diff --git a/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js b/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js index 807bdff70dd..8f0724bbb00 100644 --- a/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js +++ b/addons/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js @@ -421,6 +421,12 @@ element.children().first().after(widget); } + // !! ODOO FIX START !! + var parentOffset = parent.offset(); + position.top = offset.top - parentOffset.top; + position.left = offset.left - parentOffset.left; + // !! ODOO FIX END !! + // Top and bottom logic if (vertical === 'auto') { if (offset.top + widget.height() * 1.5 >= $(window).height() + $(window).scrollTop() && diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 9f56cd01ef4..481e9a7fc54 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -82,6 +82,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { } }); }, + show_wow: '_onShowWow', }, init: function (parent) { this.client_options = {}; @@ -319,6 +320,22 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { this.notification_manager.warn(e.data.title, e.data.message, e.data.sticky); } }, + /** + * Displays a thumb up, heart or peace image (randomly) for a moment (e.g. + * used when an opportunity is won) + * + * @private + */ + _onShowWow: function () { + var className = 'o_wow_thumbs'; + if (Math.random() > 0.9) { + var otherClasses = ['o_wow_peace', 'o_wow_heart']; + className = otherClasses[Math.floor(Math.random()*otherClasses.length)]; + } + var $body = $('body'); + $body.addClass(className); + setTimeout($body.removeClass.bind($body, className), 1000); + }, }); return AbstractWebClient; diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index 5908f7f5a8b..492d9f26552 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -176,8 +176,8 @@ var AbstractField = Widget.extend({ * focusable element was not found or invisible */ activate: function (options) { - var $focusable = this.getFocusableElement(); - if ($focusable.length && $focusable.is(':visible')) { + if (this.isFocusable()) { + var $focusable = this.getFocusableElement(); $focusable.focus(); if ($focusable.is('input[type="text"], textarea')) { $focusable[0].selectionStart = $focusable[0].selectionEnd = $focusable[0].value.length; @@ -212,6 +212,15 @@ var AbstractField = Widget.extend({ return $(); }, /** + * Returns true iff the widget has a visible element that can take the focus + * + * @returns {boolean} + */ + isFocusable: function () { + var $focusable = this.getFocusableElement(); + return $focusable.length && $focusable.is(':visible'); + }, + /** * this method is used to determine if the field value is set to a meaningful * value. This is useful to determine if a field should be displayed as empty * @@ -351,12 +360,13 @@ var AbstractField = Widget.extend({ * @param {Object} [options] * @param {boolean} [options.forceChange=false] if true, the change event will be * triggered even if the new value is the same as the old one + * @returns {Deferred} */ _setValue: function (value, options) { // we try to avoid doing useless work, if the value given has not // changed. Note that we compare the unparsed values. if (this.lastSetValue === value || (this.value === false && value === '')) { - return; + return $.when(); } this.lastSetValue = value; try { @@ -364,18 +374,22 @@ var AbstractField = Widget.extend({ this._isValid = true; } catch (e) { this._isValid = false; - return; + return $.Deferred().reject(); } if (!(options && options.forceChange) && this._isSameValue(value)) { - return; + return $.when(); } + var def = $.Deferred(); var changes = {}; changes[this.name] = value; this.trigger_up('field_changed', { dataPointID: this.dataPointID, changes: changes, viewType: this.viewType, + onSuccess: def.resolve.bind(def), + onFailure: def.reject.bind(def), }); + return def; }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index a5ceba50d3f..d4013a8bcdb 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1281,8 +1281,26 @@ var PriorityWidget = AbstractField.extend({ }, }); -var AttachmentImage = AbstractField.extend({ - template: 'AttachmentImage', +var AttachmentImage = AbstractField.extend({ + className: 'o_attachment_image', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Reset cover image when widget value change + * + * @private + */ + _render: function () { + if (this.value) { + this.$el.empty().append($('<img>/', { + src: "/web/image/" + this.value.data.id + "?unique=1", + title: this.value.data.display_name + })); + } + } }); var StateSelectionWidget = AbstractField.extend({ diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index e13c3d29004..ae3b06d3a02 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -213,17 +213,20 @@ var FieldMany2One = AbstractField.extend({ var self = this; var slowCreate = this._searchCreatePopup.bind(this, "form", false, this._createContext(name)); if (this.nodeOptions.quick_create) { - return this._rpc({ - model: this.field.relation, - method: 'name_create', - args: [name], - context: this.record.getContext(this.recordParams), - }) - .then(function (result) { - if (self.mode === "edit") { - self.reinitialize({id: result[0], display_name: result[1]}); - } - }, slowCreate); + this.trigger_up('mutexify', { + action: function () { + return self._rpc({ + model: self.field.relation, + method: 'name_create', + args: [name], + context: self.record.getContext(self.recordParams), + }).then(function (result) { + if (self.mode === "edit") { + self.reinitialize({id: result[0], display_name: result[1]}); + } + }, slowCreate); + }, + }); } else { slowCreate(); } @@ -964,7 +967,6 @@ var FieldOne2Many = FieldX2Many.extend({ var index = self.editable === 'top' ? 0 : self.value.data.length - 1; var newID = self.value.data[index].id; self.renderer.editRecord(newID); - self.creatingRecord = false; } } }); @@ -1014,6 +1016,7 @@ var FieldOne2Many = FieldX2Many.extend({ * in the kanban view */ _onAddRecord: function (ev) { + var self = this; // we don't want interference with the components upstream. ev.stopPropagation(); @@ -1023,10 +1026,11 @@ var FieldOne2Many = FieldX2Many.extend({ this._setValue({ operation: 'CREATE', position: this.editable, + }).always(function () { + self.creatingRecord = false; }); } } else { - var self = this; this._openFormDialog({ on_saved: function (record) { self._setValue({ operation: 'ADD', id: record.id }); diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index 5131db19028..41fc293e383 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -11,6 +11,7 @@ var utils = require('web.utils'); return core.Class.extend({ init: function () { this._init_cache(); + core.bus.on('clear_cache', this, this.invalidate.bind(this)); }, _init_cache: function () { diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 53e5be8ff6e..16a066db495 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -46,6 +46,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @returns {Deferred} */ start: function () { + // add classname to reflect the (absence of) access rights (used to + // correctly display the nocontent helper) + this.$el.toggleClass('o_cannot_create', !this.activeActions.create); return this._super.apply(this, arguments) .then(this._updateEnv.bind(this)); }, @@ -186,8 +189,12 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { // user if some discarding has to be made). This operation must also be // mutex-protected as commitChanges function of x2m has to be aware of // all final changes made to a row. - this.mutex.exec(this.renderer.commitChanges.bind(this.renderer, recordID || this.handle)); // TODO write a test for this - return this.mutex.exec(this._saveRecord.bind(this, recordID, options)); + var self = this; + return this.mutex + .exec(this.renderer.commitChanges.bind(this.renderer, recordID || this.handle)) + .then(function () { + return self.mutex.exec(self._saveRecord.bind(self, recordID, options)); + }); }, /** * @override 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 60abfde4344..61c285d6581 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1841,7 +1841,6 @@ var BasicModel = AbstractModel.extend({ return d.res_id === res_id; }); m2mList.data.push(dataPoint.id); - m2mList.count++; }); }); }); @@ -2476,7 +2475,10 @@ var BasicModel = AbstractModel.extend({ // REPLACE_WITH x2manyList.res_ids = value[2]; x2manyList.count = x2manyList.res_ids.length; - defs.push(self._readUngroupedList(x2manyList)); + var def = self._readUngroupedList(x2manyList).then(function () { + return self._fetchX2ManysBatched(x2manyList); + }); + defs.push(def); } }); diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js index 9578e568558..422af121d96 100644 --- a/addons/web/static/src/js/views/field_manager_mixin.js +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -99,7 +99,9 @@ var FieldManagerMixin = { // subrecord's form view), otherwise it bubbles up to the main form view // but its model doesn't have any data related to the given dataPointID event.stopPropagation(); - this._applyChanges(event.data.dataPointID, event.data.changes, event); + this._applyChanges(event.data.dataPointID, event.data.changes, event) + .done(event.data.onSuccess || function () {}) + .fail(event.data.onFailure || function () {}); }, /** * Some widgets need to trigger a reload of their data. For example, a 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 ce449f2dcc9..8eef70a916c 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -39,12 +39,10 @@ var FormController = BasicController.extend({ //-------------------------------------------------------------------------- /** - * This method is supposed to focus the first active control, I think. It - * is currently only called by the FormViewDialog. - * - * @todo To be implemented + * Calls autofocus on the renderer */ autofocus: function () { + this.renderer.autofocus(); }, /** * This method switches the form view in edit mode, with a new record. @@ -93,6 +91,14 @@ var FormController = BasicController.extend({ return dataPoint.data.display_name || _t('New'); }, /** + * Called each time the form view is attached into the DOM + * + * @todo convert to new style + */ + on_attach_callback: function () { + this.autofocus(); + }, + /** * Render buttons for the control panel. The form view can be rendered in * a dialog, and in that case, if we have buttons defined in the footer, we * have to use them instead of the standard buttons. @@ -374,10 +380,8 @@ var FormController = BasicController.extend({ }); } - if (event.data.show_wow) { - def.then(function () { - self.show_wow(); - }); + if (event.data.showWow) { + def.then(this.trigger_up.bind(this, 'show_wow')); } def.always(this._enableButtons.bind(this)); 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 ddd77ee5dc6..08f28438368 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -26,6 +26,29 @@ var FormRenderer = BasicRenderer.extend({ //-------------------------------------------------------------------------- /** + * Focuses the field having attribute 'default_focus' set, if any, or the + * first focusable field otherwise. + */ + autofocus: function () { + if (this.mode === 'readonly') { + return; + } + var focusWidget = this.defaultFocusField; + if (!focusWidget || !focusWidget.isFocusable()) { + var widgets = this.allFieldWidgets[this.state.id]; + for (var i = 0; i < (widgets ? widgets.length : 0); i++) { + var widget = widgets[i]; + if (widget.isFocusable()) { + focusWidget = widget; + break; + } + } + } + if (focusWidget) { + focusWidget.activate({noselect: true}); + } + }, + /** * Extend the method so that labels also receive the 'o_field_invalid' class * if necessary. * @@ -183,7 +206,7 @@ var FormRenderer = BasicRenderer.extend({ self.trigger_up('button_clicked', { attrs: node.attrs, record: self.state, - show_wow: self.$el.hasClass('o_wow'), // TODO: implement this (in view) + showWow: $el.hasClass('o_wow'), }); }); }, @@ -709,7 +732,10 @@ var FormRenderer = BasicRenderer.extend({ * @returns {jQueryElement} */ _renderTagSeparator: function (node) { - return $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + var $separator = $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + this._handleAttributes($separator, node); + this._registerModifiers(node, this.state, $separator); + return $separator; }, /** * @private @@ -769,20 +795,16 @@ var FormRenderer = BasicRenderer.extend({ core.bus.trigger('DOM_updated'); // Attach the tooltips on the fields' label - var focusWidget = this.defaultFocusField; _.each(this.allFieldWidgets[this.state.id], function (widget) { - if (!focusWidget) { - focusWidget = widget; - } if (core.debug || widget.attrs.help || widget.field.help) { var idForLabel = self.idsForLabels[widget.name]; var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); self._addFieldTooltip(widget, $label); } }); - if (focusWidget) { - focusWidget.activate({noselect: true}); - } + + // Focus the default field (in edit mode) + self.autofocus(); }, /** * Sets id attribute of given widget to idForLabel 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 9364d1ba9fe..08b9e7f1602 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 @@ -155,7 +155,7 @@ ListRenderer.include({ var self = this; var rowIndex = _.findIndex(this.state.data, {id: recordID}); if (rowIndex < 0) { - return; + return $.when(); } var editMode = (mode === 'edit'); var record = this.state.data[rowIndex]; diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index b84d60d5b7c..a19e440913e 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -4,6 +4,7 @@ odoo.define('web.view_dialogs', function (require) { var core = require('web.core'); var data = require('web.data'); var Dialog = require('web.Dialog'); +var dom = require('web.dom'); var ListController = require('web.ListController'); var ListView = require('web.ListView'); var pyeval = require('web.pyeval'); @@ -194,8 +195,10 @@ var FormViewDialog = ViewDialog.extend({ if ($buttons.children().length) { self.$footer.empty().append($buttons.contents()); } - _super().$el.append(fragment); - self.form_view.autofocus(); + dom.append(_super().$el, fragment, { + callbacks: [{widget: self.form_view}], + in_DOM: true, + }); }); }); @@ -281,6 +284,7 @@ var SelectCreateDialog = ViewDialog.extend({ if(this.options.initial_view !== "search") { return this.create_edit_record(); } + var self = this; var user_context = this.getSession().user_context; var _super = this._super.bind(this); @@ -298,7 +302,10 @@ var SelectCreateDialog = ViewDialog.extend({ this.loadViews(this.dataset.model, this.dataset.get_context(), [[false, 'list'], [false, 'search']], {}) .then(this.setup.bind(this, search_defaults)) .then(function (fragment) { - _super().$el.append(fragment); + dom.append(_super().$el, fragment, { + callbacks: [{widget: self.list_controller}], + in_DOM: true, + }); }); return this; }, diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index 353a5dc78f2..76281d9366a 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -42,6 +42,7 @@ var DateWidget = Widget.extend({ locale : moment.locale(), allowInputToggle: true, keyBinds: null, + widgetParent: 'body', }); }, /** diff --git a/addons/web/static/src/js/widgets/model_field_selector.js b/addons/web/static/src/js/widgets/model_field_selector.js index 279428e8939..6607561a2bb 100644 --- a/addons/web/static/src/js/widgets/model_field_selector.js +++ b/addons/web/static/src/js/widgets/model_field_selector.js @@ -16,6 +16,11 @@ var modelFieldsCache = { cacheDefs: {}, }; +core.bus.on('clear_cache', null, function () { + modelFieldsCache.cache = {}; + modelFieldsCache.cacheDefs = {}; +}); + /** * The ModelFieldSelector widget can be used to display/select a particular * field chain from a given model. diff --git a/addons/web/static/src/less/model_field_selector.less b/addons/web/static/src/less/model_field_selector.less index 124c15cb8e5..aee5bd17867 100644 --- a/addons/web/static/src/less/model_field_selector.less +++ b/addons/web/static/src/less/model_field_selector.less @@ -7,6 +7,10 @@ .o-flex-flow(row, wrap); .o-align-items(center); height: 100%; + min-height: 20px; // needed when there is no value in it and used standalone + &:active, &:focus, &:active:focus { + outline: none; + } > .o_field_selector_chain_part { padding: 0px 1px; diff --git a/addons/web/static/src/less/webclient.less b/addons/web/static/src/less/webclient.less index 2f9878fbdc3..5e84d74b599 100644 --- a/addons/web/static/src/less/webclient.less +++ b/addons/web/static/src/less/webclient.less @@ -12,6 +12,7 @@ a { // General // ------------------------------------------------------------------ .o_web_client { + position: relative; // normally useless but required by bootstrap-datepicker background-color: @odoo-webclient-background-color; } .o_text_overflow { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 394abe18c7c..20e38e98912 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -24,7 +24,7 @@ <div t-name="CrashManager.warning" class="o_dialog_warning"> <t t-js="d"> - var message = d.message ? d.message : d.error.data.message; + var message = (d.message !== undefined) ? d.message : d.error.data.message; d.html_error = context.engine.tools.html_escape(message).replace(/\n/g, '<br/>'); </t> <t t-raw="html_error"/> @@ -836,11 +836,6 @@ 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> -<t t-name="AttachmentImage"> - <div class="o_attachment_image"> - <img t-att-src="'/web/image/' + widget.value + '?unique=1'"/> - </div> -</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/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 4f326e14f98..127d5761fd2 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -482,6 +482,183 @@ QUnit.module('relational_fields', { // assert.strictEqual(form.$('input').eq(1).val(), 'xphone', "onchange should have been applied"); // }); + QUnit.test('form: quick create then save directly', function (assert) { + var done = assert.async(); + assert.expect(5); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var def = $.Deferred(); + var newRecordID; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="trululu"/>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + assert.step('name_create'); + return def.then(_.constant(result)).then(function (nameGet) { + newRecordID = nameGet[0]; + return nameGet; + }); + } + if (args.method === 'create') { + assert.step('create'); + assert.strictEqual(args.args[0].trululu, newRecordID, + "should create with the correct m2o id"); + } + return result; + }, + }); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + form.$('.o_field_many2one input').val('b').trigger('keydown'); + concurrency.delay(0).then(function () { + $dropdown.find('li:first()').click(); // quick create 'b' + form.$buttons.find('.o_form_button_save').click(); + + assert.verifySteps(['name_create'], + "should wait for the name_create before creating the record"); + + def.resolve(); + + assert.verifySteps(['name_create', 'create']); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + + QUnit.test('list: quick create then save directly', function (assert) { + var done = assert.async(); + assert.expect(8); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var def = $.Deferred(); + var newRecordID; + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="top">' + + '<field name="trululu"/>' + + '</tree>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + assert.step('name_create'); + return def.then(_.constant(result)).then(function (nameGet) { + newRecordID = nameGet[0]; + return nameGet; + }); + } + if (args.method === 'create') { + assert.step('create'); + assert.strictEqual(args.args[0].trululu, newRecordID, + "should create with the correct m2o id"); + } + return result; + }, + }); + + list.$buttons.find('.o_list_button_add').click(); + + var $dropdown = list.$('.o_field_many2one input').autocomplete('widget'); + list.$('.o_field_many2one input').val('b').trigger('keydown'); + concurrency.delay(0).then(function () { + $dropdown.find('li:first()').click(); // quick create 'b' + list.$buttons.find('.o_list_button_add').click(); + + assert.verifySteps(['name_create'], + "should wait for the name_create before creating the record"); + assert.strictEqual(list.$('.o_data_row').length, 4, + "should wait for the name_create before adding the new row"); + + def.resolve(); + + assert.verifySteps(['name_create', 'create']); + assert.strictEqual(list.$('.o_data_row:nth(1) .o_data_cell').text(), 'b', + "created row should have the correct m2o value"); + assert.strictEqual(list.$('.o_data_row').length, 5, + "should have added the fifth row"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + list.destroy(); + done(); + }); + }); + + QUnit.test('list in form: quick create then save directly', function (assert) { + var done = assert.async(); + assert.expect(6); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var def = $.Deferred(); + var newRecordID; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="trululu"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + assert.step('name_create'); + return def.then(_.constant(result)).then(function (nameGet) { + newRecordID = nameGet[0]; + return nameGet; + }); + } + if (args.method === 'create') { + assert.step('create'); + assert.strictEqual(args.args[0].p[0][2].trululu, newRecordID, + "should create with the correct m2o id"); + } + return result; + }, + }); + + form.$('.o_field_x2many_list_row_add a').click(); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + form.$('.o_field_many2one input').val('b').trigger('keydown'); + concurrency.delay(0).then(function () { + $dropdown.find('li:first()').click(); // quick create 'b' + form.$buttons.find('.o_form_button_save').click(); + + assert.verifySteps(['name_create'], + "should wait for the name_create before creating the record"); + + def.resolve(); + + assert.verifySteps(['name_create', 'create']); + assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b', + "first row should have the correct m2o value"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) { assert.expect(1); @@ -3852,6 +4029,115 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('editable list: value reset by an onchange', function (assert) { + // this test reproduces a subtle behavior that may occur in a form view: + // the user adds a record in a one2many field, and directly clicks on a + // datetime field of the form view which has an onchange, which totally + // overrides the value of the one2many (commands 5 and 0). The handler + // that switches the edited row to readonly is then called after the + // new value of the one2many field is applied (the one returned by the + // onchange), so the row that must go to readonly doesn't exist anymore. + assert.expect(2); + + this.data.partner.onchanges = { + datetime: function (obj) { + obj.turtles = [[5], [0, 0, {display_name: 'new'}]]; + }, + }; + + var def; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="datetime"/>' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + // trigger the two onchanges + form.$('.o_field_x2many_list_row_add a').click(); + form.$('.o_data_row .o_field_widget').val('a name').trigger('input'); + def = $.Deferred(); + form.$('.o_datepicker_input').click(); // focusout o2m and set value to today + + // resolve the onchange def + def.resolve(); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "should have one record in the o2m"); + assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'new', + "should be the record created by the onchange"); + + form.destroy(); + }); + + QUnit.test('editable list: onchange that returns a warning', function (assert) { + assert.expect(5); + + this.data.turtle.onchanges = { + display_name: function () {}, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.step(args.method); + return $.when({ + value: {}, + warning: { + title: "Warning", + message: "You must first select a partner" + }, + }); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + intercepts: { + warning: function () { + assert.step('warning'); + }, + }, + }); + + // add a line (this should trigger an onchange and a warning) + form.$('.o_field_x2many_list_row_add a').click(); + + // check if 'Add an item' still works (this should trigger an onchange + // and a warning again) + form.$('.o_field_x2many_list_row_add a').click(); + + assert.verifySteps(['onchange', 'warning', 'onchange', 'warning']); + + form.destroy(); + }); + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index eae31e68e80..bc2cf05f360 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -13,6 +13,7 @@ odoo.define('web.test_utils', function (require) { var basic_fields = require('web.basic_fields'); var config = require('web.config'); var core = require('web.core'); +var dom = require('web.dom'); var session = require('web.session'); var MockServer = require('web.MockServer'); var Widget = require('web.Widget'); @@ -172,7 +173,14 @@ function createAsyncView(params) { widget.destroy(); $('#qunit-fixture').off('DOMNodeInserted.removeSRC'); }; - return view.appendTo($view_manager).then(function () { + // render the view in a fragment as they must be able to render correctly + // without being in the DOM + var fragment = document.createDocumentFragment(); + return view.appendTo(fragment).then(function () { + dom.append($view_manager, fragment, { + callbacks: [{widget: view}], + in_DOM: true, + }); view.$el.on('click', 'a', function (ev) { ev.preventDefault(); }); @@ -244,52 +252,53 @@ function addMockEnvironment(widget, params) { logLevel: params.logLevel, currentDate: params.currentDate, }); - var widgetDestroy; - // make sure the debounce value for input fields is set to 0 var initialDebounce = DebouncedField.prototype.DEBOUNCE; DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0; - + var initialSession, initialConfig, initialParameters; if ('session' in params) { - var initialSession = _.extend({}, session); + 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); + initialConfig = _.extend({}, config); _.extend(config, params.config); - widgetDestroy = widget.destroy; - widget.destroy = function () { - for (var key in config) { - delete config[key]; - } - _.extend(config, initialConfig); - DebouncedField.prototype.DEBOUNCE = initialDebounce; - widgetDestroy.call(this); - }; } - if ('translateParameters' in params) { - var initialParameters = _.extend({}, core._t.database.parameters); + 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) { + } + + var widgetDestroy = widget.destroy; + widget.destroy = function () { + // clear the caches (e.g. data_manager, ModelFieldSelector) when the + // widget is destroyed, at the end of each test to avoid collisions + core.bus.trigger('clear_cache'); + + DebouncedField.prototype.DEBOUNCE = initialDebounce; + + var key; + if ('session' in params) { + for (key in session) { + delete session[key]; + } + _.extend(session, initialSession); + } + if ('config' in params) { + for (key in config) { + delete config[key]; + } + _.extend(config, initialConfig); + } + if ('translateParameters' in params) { + for (key in core._t.database.parameters) { delete core._t.database.parameters[key]; } _.extend(core._t.database.parameters, initialParameters); - oldDestroy.call(this); - }; - } + } + widgetDestroy.call(this); + }; intercept(widget, 'call_service', function (event) { if (event.data.service === 'ajax') { diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 582e06ba62b..fefc7231e5b 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -1165,6 +1165,54 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('default_get: fetch x2manys inside x2manys', function (assert) { + assert.expect(3); + + this.data.partner.fields.o2m = { + string: "O2M", type: 'one2many', relation: 'partner', default: [[6, 0, [1]]], + }; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + var params = { + fieldNames: ['o2m'], + fields: this.data.partner.fields, + fieldsInfo: { + form: { + o2m: { + relatedFields: this.data.partner.fields, + fieldsInfo: { + list: { + category: { + relatedFields: { display_name: {} }, + }, + }, + }, + viewType: 'list', + }, + }, + }, + modelName: 'partner', + type: 'record', + viewType: 'form', + }; + + model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.o2m.count, 1, "o2m field should contain 1 record"); + var categoryList = record.data.o2m.data[0].data.category; + assert.strictEqual(categoryList.count, 1, + "category field should contain 1 record"); + assert.strictEqual(categoryList.data[0].data.display_name, + 'gold', "category records should have been fetched"); + }); + + model.destroy(); + }); + QUnit.test('contexts and domains can be properly fetched', function (assert) { assert.expect(8); @@ -1448,6 +1496,7 @@ QUnit.module('Views', { fieldsInfo: { form: { product_ids: { + relatedFields: this.data.product.fields, fieldsInfo: { list: { name: {}, date: {} } }, viewType: 'list', } diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index a07f022eb74..3f829a654b7 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -837,6 +837,28 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('invisible attrs on 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" attrs=\'{"invisible": [["bar", "=", True]]}\'/>'+ + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + assert.strictEqual(form.$('div.o_horizontal_separator').hasClass('o_invisible_modifier'), true, + "separator div should be hidden"); + form.destroy(); + }); + QUnit.test('buttons in form view', function (assert) { assert.expect(7); @@ -3770,7 +3792,7 @@ QUnit.module('Views', { }); form.$buttons.find('.o_form_button_edit').click(); - assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], "foo field should have focus"); assert.strictEqual(form.$('input[name="foo"]')[0].selectionStart, 3, "cursor should be at the end"); @@ -3792,7 +3814,25 @@ QUnit.module('Views', { res_id: 1, }); form.$buttons.find('.o_form_button_edit').click(); - assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + + form.destroy(); + }); + + QUnit.test('in create mode, autofocus fields are focused', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field"/>' + + '<field name="foo" default_focus="1"/>' + + '</form>', + }); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], "foo field should have focus"); form.destroy(); @@ -3821,6 +3861,24 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('autofocus first visible field', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="int_field" invisible="1"/>' + + '<field name="foo"/>' + + '</form>', + }); + assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], + "foo field should have focus"); + + form.destroy(); + }); + QUnit.test('open one2many form containing many2many_tags', function (assert) { assert.expect(4); @@ -4893,5 +4951,40 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('wow effect', function (assert) { + assert.expect(3); + + this.data.partner.fields.model_name = { string: "Model name", type: "char" }; + + var clickCount = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<button name="test" string="Mark Won" type="object" class="o_wow"/>' + + '</form>', + intercepts: { + execute_action: function (event) { + if (clickCount === 0) { + event.data.on_success(); + } else { + event.data.on_fail(); + } + clickCount++; + }, + show_wow: function () { + assert.step('wow'); + }, + }, + }); + + form.$('button.o_wow').click(); + assert.verifySteps(['wow']); + form.$('button.o_wow').click(); + assert.verifySteps(['wow']); + + form.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index e1cfef2b23b..332ba6687f9 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -84,7 +84,7 @@ QUnit.module('Views', { QUnit.module('ListView'); QUnit.test('simple readonly list', function (assert) { - assert.expect(9); + assert.expect(10); var list = createView({ View: ListView, @@ -93,6 +93,9 @@ QUnit.module('Views', { arch: '<tree><field name="foo"/><field name="int_field"/></tree>', }); + assert.notOk(list.$el.hasClass('o_cannot_create'), + "should not have className 'o_cannot_create'"); + // 3 th (1 for checkbox, 2 for columns) assert.strictEqual(list.$('th').length, 3, "should have 3 columns"); @@ -114,6 +117,24 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('list with create="0"', function (assert) { + assert.expect(2); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree create="0"><field name="foo"/></tree>', + }); + + assert.ok(list.$el.hasClass('o_cannot_create'), + "should have className 'o_cannot_create'"); + assert.strictEqual(list.$buttons.find('.o_list_button_add').length, 0, + "should not have the 'Create' button"); + + list.destroy(); + }); + QUnit.test('simple editable rendering', function (assert) { assert.expect(9); |
