diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-05-29 17:34:43 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-05-29 17:34:43 +0200 |
| commit | f36e6917bd73a56dc0c7468b4849920df4088154 (patch) | |
| tree | b63d02ae3e5ffc0815de009407363e0cf906f547 /addons/web/static | |
| parent | bc0173c2d58ac26d80cb2f483a95c0c13b047dab (diff) | |
| parent | 37eed7c509fb5846c89ef37046df4b3cab299719 (diff) | |
[MERGE] forward port branch saas-11.3 up to 37eed7c509f
Diffstat (limited to 'addons/web/static')
28 files changed, 945 insertions, 139 deletions
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index 8ae6d93bc58..9885d684dec 100644 --- a/addons/web/static/src/js/chrome/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -775,17 +775,17 @@ var ActionManager = Widget.extend({ * @param {Object} options see @doAction options */ _preprocessAction: function (action, options) { - action._originalAction = JSON.stringify(action); - - action.jsID = _.uniqueId('action_'); - action.pushState = options.pushState; - // ensure that the context and domain are evaluated var context = new Context(this.userContext, options.additional_context, action.context); action.context = pyeval.eval('context', context); if (action.domain) { action.domain = pyeval.eval('domain', action.domain, action.context); } + + action._originalAction = JSON.stringify(action); + + action.jsID = _.uniqueId('action_'); + action.pushState = options.pushState; }, /** * Unlinks the given action and its controller from the internal structures diff --git a/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js index dc1b5247cb1..d0eed176d9c 100644 --- a/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js +++ b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js @@ -26,7 +26,6 @@ odoo.define('web.KeyboardNavigationMixin', function (require) { var KeyboardNavigationMixin = { events: { 'keydown': '_onKeyDown', - 'keypress': '_onKeyPress', 'keyup': '_onKeyUp', }, @@ -127,6 +126,11 @@ odoo.define('web.KeyboardNavigationMixin', function (require) { } } }); + + var elementsWithoutAriaKeyshortcut = this.$el.find('[accesskey]').not('[aria-keyshortcuts]'); + _.each(elementsWithoutAriaKeyshortcut, function (elem) { + elem.setAttribute('aria-keyshortcuts', 'Alt+Shift+' + elem.accessKey); + }); this._addAccessKeyOverlays(); } if (keyDownEvent.altKey && !keyDownEvent.ctrlKey && keyDownEvent.key.length === 1) { // we don't want to catch the Alt key down, only the characters A to Z and number keys @@ -138,6 +142,7 @@ odoo.define('web.KeyboardNavigationMixin', function (require) { if (elementWithAccessKey.length) { if (this.BrowserDetection.isOsMac() || !this.BrowserDetection.isBrowserChrome()) { // on windows and linux, chrome does not prevent the default of the accesskeys + elementWithAccessKey[0].focus(); elementWithAccessKey[0].click(); if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false; if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation(); @@ -178,21 +183,6 @@ odoo.define('web.KeyboardNavigationMixin', function (require) { } }, /** - * hides the shortcut overlays when key press event is triggered on the ALT key - * - * @private - * @param keyPressEvent {jQueryKeyboardEvent} the keyboard event triggered - * @return {undefined|false} - */ - _onKeyPress: function (keyPressEvent) { - if ((keyPressEvent.altKey || keyPressEvent.key === 'Alt') && !keyPressEvent.ctrlKey) { - if (keyPressEvent.preventDefault) keyPressEvent.preventDefault(); else keyPressEvent.returnValue = false; - if (keyPressEvent.stopPropagation) keyPressEvent.stopPropagation(); - if (keyPressEvent.cancelBubble) keyPressEvent.cancelBubble = true; - return false; - } - }, - /** * hides the shortcut overlays when keyup event is triggered on the ALT key * * @private diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index 0ee6d2ad1ed..d6c67eacaec 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -260,8 +260,20 @@ function get_file(options) { if (options.error) { var body = this.contentDocument.body; var nodes = body.children.length === 0 ? body.childNodes : body.children; - var node = nodes[1] || nodes[0]; - options.error(JSON.parse(node.textContent)); + var errorParams = {}; + + try { // Case of a serialized Odoo Exception: It is Json Parsable + var node = nodes[1] || nodes[0]; + errorParams = JSON.parse(node.textContent); + } catch (e) { // Arbitrary uncaught python side exception + errorParams = { + message: nodes.length > 1 ? nodes[1].textContent : '', + data: { + title: nodes.length > 0 ? nodes[0].textContent : '', + } + } + } + options.error(errorParams); } } finally { complete(); diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 10057d44984..31b0c53dd64 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -622,7 +622,7 @@ var FieldMonetary = InputField.extend({ this.$el.empty(); // Prepare and add the input - this._prepareInput().appendTo(this.$el); + this._prepareInput(this.$input).appendTo(this.$el); if (this.currency) { // Prepare and add the currency symbol diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 7044a79a535..62cc965cc15 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -340,8 +340,8 @@ var FieldMany2One = AbstractField.extend({ _renderReadonly: function () { var value = _.escape((this.m2o_value || "").trim()).split("\n").join("<br/>"); this.$el.html(value); - if (!this.nodeOptions.no_open) { - this.$el.attr('href', '#'); + if (!this.nodeOptions.no_open && this.value) { + this.$el.attr('href', _.str.sprintf('#id=%s&model=%s', this.value.res_id, this.field.relation)); this.$el.addClass('o_form_uri'); } }, @@ -1334,10 +1334,7 @@ var FieldOne2Many = FieldX2Many.extend({ var self = this; var id = ev.data.id; - var onSaved = function (record, hasChanged) { - if (!hasChanged) { - return; - } + var onSaved = function (record) { if (_.some(self.value.data, {id: record.id})) { // the record already exists in the relation, so trigger an // empty 'UPDATE' operation when the user clicks on 'Save' in diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index fe4ba14de71..2f9957737ae 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -12,6 +12,7 @@ odoo.define('web.AbstractController', function (require) { * reading localstorage, ...) has to go through the controller. */ +var concurrency = require('web.concurrency'); var ControlPanelMixin = require('web.ControlPanelMixin'); var core = require('web.core'); var AbstractAction = require('web.AbstractAction'); @@ -54,6 +55,8 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { this.controllerID = params.controllerID; this.initialState = params.initialState; + // use a DropPrevious to correctly handle concurrent updates + this.dp = new concurrency.DropPrevious(); // those arguments are temporary, they won't be necessary as soon as the // ControlPanel will be handled by the View this.displayName = params.displayName; @@ -232,11 +235,11 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { var self = this; var shouldReload = (options && 'reload' in options) ? options.reload : true; var def = shouldReload ? this.model.reload(this.handle, params) : $.when(); - return def.then(function (handle) { + return this.dp.add(def).then(function (handle) { self.handle = handle || self.handle; // update handle if we reloaded var state = self.model.get(self.handle); var localState = self.renderer.getLocalState(); - return self.renderer.updateState(state, params).then(function () { + return self.dp.add(self.renderer.updateState(state, params)).then(function () { self.renderer.setLocalState(localState); self._update(state); }); diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js index 2394fb31a64..059380f87bd 100644 --- a/addons/web/static/src/js/views/abstract_renderer.js +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -67,6 +67,11 @@ return Widget.extend({ getLocalState: function () { }, /** + * Order to focus to be given to the content of the current view + */ + giveFocus: function () { + }, + /** * This is the reverse operation from getLocalState. With this method, we * expect the renderer to restore all DOM state, if it is relevant. * 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 ec0fad66a06..cc3dbd67595 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1068,7 +1068,13 @@ var BasicModel = AbstractModel.extend({ addFieldsInfo: function (recordID, viewInfo) { var record = this.localData[recordID]; record.fields = _.extend({}, record.fields, viewInfo.fields); - record.fieldsInfo = _.extend({}, record.fieldsInfo, viewInfo.fieldsInfo); + // complete the given fieldsInfo with the fields of the main view, so + // that those field will be reloaded if a reload is triggered by the + // secondary view + var fieldsInfo = _.mapObject(viewInfo.fieldsInfo, function (fieldsInfo) { + return _.defaults({}, fieldsInfo, record.fieldsInfo[record.viewType]); + }); + record.fieldsInfo = _.extend({}, record.fieldsInfo, fieldsInfo); }, /** * For list resources, this freezes the current records order. @@ -1553,8 +1559,10 @@ var BasicModel = AbstractModel.extend({ rec = self._makeDataPoint(params); list._cache[rec.res_id] = rec.id; } - - rec._noAbandon = true; + // Do not abandon the record if it has been created + // from `default_get`. The list has a savepoint only + // after having fully executed `default_get`. + rec._noAbandon = !list._savePoint; list._changes.push({operation: 'ADD', id: rec.id}); if (command[0] === 1) { list._changes.push({operation: 'UPDATE', id: rec.id}); @@ -1973,6 +1981,9 @@ var BasicModel = AbstractModel.extend({ var records = []; var ids = []; list = this._applyX2ManyOperations(list); + if (_.isEmpty(list.data)) { + return $.when(); + } _.each(list.data, function (localId) { var record = self.localData[localId]; var data = record._changes || record.data; @@ -1986,13 +1997,16 @@ var BasicModel = AbstractModel.extend({ return this._rpc({ model: model, method: 'name_get', - args: [ids], + args: [_.uniq(ids)], context: list.context, }) .then(function (name_gets) { - for (var i = 0; i < name_gets.length; i++) { - records[i].data.display_name = name_gets[i][1]; - } + _.each(records, function (record) { + var nameGet = _.find(name_gets, function (nameGet) { + return nameGet[0] === record.data.id; + }); + record.data.display_name = nameGet[1]; + }); }); }, /** @@ -2010,7 +2024,8 @@ var BasicModel = AbstractModel.extend({ */ _fetchRecord: function (record, options) { var self = this; - var fieldNames = options && options.fieldNames || record.getFieldNames(); + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); fieldNames = _.uniq(fieldNames.concat(['display_name'])); return this._rpc({ model: record.model, @@ -2477,7 +2492,16 @@ var BasicModel = AbstractModel.extend({ var self = this; var def; if (list.static) { - def = this._readUngroupedList(list); + def = this._readUngroupedList(list).then(function () { + if (list.parentID && self.isNew(list.parentID)) { + // list from a default_get, so fetch display_name for many2one fields + var many2ones = self._getMany2OneFieldNames(list); + var defs = _.map(many2ones, function (name) { + return self._fetchNameGets(list, name); + }); + return $.when.apply($, defs); + } + }); } else { def = this._searchReadUngroupedList(list); } @@ -2510,8 +2534,9 @@ var BasicModel = AbstractModel.extend({ _fetchX2Manys: function (record, options) { var self = this; var defs = []; - var fieldNames = options && options.fieldNames || record.getFieldNames(); - var viewType = options && options.viewType || record.viewType; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + var viewType = options.viewType || record.viewType; _.each(fieldNames, function (fieldName) { var field = record.fields[fieldName]; if (field.type === 'one2many' || field.type === 'many2many') { @@ -3034,11 +3059,32 @@ var BasicModel = AbstractModel.extend({ * default view type. * * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record * @returns {string[]} the list of field names */ - _getFieldNames: function (element) { + _getFieldNames: function (element, options) { var fieldsInfo = element.fieldsInfo; - return Object.keys(fieldsInfo && fieldsInfo[element.viewType] || {}); + var viewType = options && options.viewType || element.viewType; + return Object.keys(fieldsInfo && fieldsInfo[viewType] || {}); + }, + /** + * Get many2one fields names in a datapoint. This is useful in order to + * fetch their names in the case of a default_get. + * + * @private + * @param {Object} datapoint a valid resource object + * @returns {string[]} list of field names that are many2one + */ + _getMany2OneFieldNames: function (datapoint) { + var many2ones = []; + _.each(datapoint.fields, function (field, name) { + if (field.type === 'many2one') { + many2ones.push(name); + } + }); + return many2ones; }, /** * Evaluate the record evaluation context. This method is supposed to be @@ -3533,13 +3579,16 @@ var BasicModel = AbstractModel.extend({ * @see _fetchRecord @see _makeDefaultRecord * * @param {Object} record - * @param {Object} record + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record * @returns {Deferred<Object>} resolves to the finished resource */ _postprocess: function (record, options) { var self = this; var defs = []; - _.each(record.getFieldNames(), function (name) { + + _.each(record.getFieldNames(options), function (name) { var field = record.fields[name]; var fieldInfo = record.fieldsInfo[record.viewType][name] || {}; var options = fieldInfo.options || {}; @@ -3879,14 +3928,20 @@ var BasicModel = AbstractModel.extend({ var emptyGroupsIDs = _.difference(_.pluck(previousGroups, 'id'), list.data); _.each(emptyGroupsIDs, function (groupID) { list.data.push(groupID); + var emptyGroup = self.localData[groupID]; + // this attribute hasn't been updated in the previous + // loop for empty groups + emptyGroup.aggregateValues = {}; }); } return $.when.apply($, defs).then(function () { - // generate the res_ids of the main list, being the concatenation - // of the fetched res_ids in each group - list.res_ids = _.flatten(_.map(arguments, function (group) { - return group ? group.res_ids : []; - })); + if (!options || !options.onlyGroups) { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(arguments, function (group) { + return group ? group.res_ids : []; + })); + } return list; }); }); @@ -4116,10 +4171,20 @@ var BasicModel = AbstractModel.extend({ var r2 = self.localData[record2ID]; var data1 = _.extend({}, r1.data, r1._changes); var data2 = _.extend({}, r2.data, r2._changes); - if (data1[order.name] < data2[order.name]) { + + // Default value to sort against: the value of the field + var orderData1 = data1[order.name]; + var orderData2 = data2[order.name]; + + // If the field is a relation, sort on the display_name of those records + if (list.fields[order.name].type === 'many2one') { + orderData1 = orderData1 ? self.localData[orderData1].data.display_name : ""; + orderData2 = orderData2 ? self.localData[orderData2].data.display_name : ""; + } + if (orderData1 < orderData2) { return order.asc ? -1 : 1; } - if (data1[order.name] > data2[order.name]) { + if (orderData1 > orderData2) { return order.asc ? 1 : -1; } return compareRecords(record1ID, record2ID, level + 1); diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index 16507184915..24b053a7bbe 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -135,13 +135,6 @@ var BasicRenderer = AbstractRenderer.extend({ dom.setSelectionRange(field.getFocusableElement().get(0), {start: offset, end: offset}); } }, - - /** - * Order to focus to be given to the content of the current view - */ - giveFocus:function() { - }, - //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js index a813a626565..6c12d508937 100644 --- a/addons/web/static/src/js/views/calendar/calendar_view.js +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -118,8 +118,20 @@ var CalendarView = AbstractView.extend({ //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate' this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true)); this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop; - // If this field is set ot true, we don't open the event in form view, but in a popup with the view_id passed by this parameter - this.controllerParams.formViewId = !attrs.form_view_id || !utils.toBoolElse(attrs.form_view_id, true) ? false : attrs.form_view_id; + + // If form_view_id is set, then the calendar view will open a form view + // with this id, when it needs to edit or create an event. + this.controllerParams.formViewId = + attrs.form_view_id ? parseInt(attrs.form_view_id, 10) : false; + if (!this.controllerParams.formViewId && params.action) { + var formViewDescr = _.find(params.action.views, function (v) { + return v[1] === 'form'; + }); + if (formViewDescr) { + this.controllerParams.formViewId = formViewDescr[0]; + } + } + this.controllerParams.readonlyFormViewId = !attrs.readonly_form_view_id || !utils.toBoolElse(attrs.readonly_form_view_id, true) ? false : attrs.readonly_form_view_id; this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false); this.controllerParams.mapping = mapping; 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 dbb64dc3682..be49c12e768 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -233,6 +233,16 @@ var FormController = BasicController.extend({ return changedFields; }); }, + /** + * Overrides to force the viewType to 'form', so that we ensure that the + * correct fields are reloaded (this is only useful for one2many form views). + * + * @override + */ + update: function (params, options) { + params = _.extend({viewType: 'form'}, params); + return this._super(params, options); + }, //-------------------------------------------------------------------------- // Private 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 607f9f54a89..f0ea360fed0 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -73,6 +73,14 @@ var FormView = BasicView.extend({ _.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) { var field = fields[fieldName]; + if (!field) { + // when a one2many record is opened in a form view, the fields + // of the main one2many view (list or kanban) are added to the + // fieldsInfo of its form view, but those fields aren't in the + // loadParams.fields, as they are not displayed in the view, so + // we can ignore them. + return; + } if (field.type !== 'one2many' && field.type !== 'many2many') { return; } diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index 411d4cc7b14..6c4ea1f133c 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -398,9 +398,8 @@ var KanbanModel = BasicModel.extend({ return $.when(); }, /** - * Reloads all progressbar data if the given id is a record's one. This is - * done after given deferred and insures that the given deferred's result is - * not lost. + * Reloads all progressbar data. This is done after given deferred and + * insures that the given deferred's result is not lost. * * @private * @param {string} recordID @@ -409,7 +408,9 @@ var KanbanModel = BasicModel.extend({ */ _reloadProgressBarGroupFromRecord: function (recordID, def) { var element = this.localData[recordID]; - if (element.type !== 'record') { + if (element.type === 'list' && !element.parentID) { + // we are reloading the whole view, so there is no need to manually + // reload the progressbars return def; } diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js index 072f4d41f27..30bafaab117 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record.js +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -23,7 +23,6 @@ var KanbanRecord = Widget.extend({ events: { 'click .oe_kanban_action': '_onKanbanActionClicked', 'click .o_kanban_manage_toggle_button': '_onManageTogglerClicked', - 'keydown' : '_onKanbanKeyDown', }, /** * @override @@ -289,6 +288,9 @@ var KanbanRecord = Widget.extend({ if (this.$el.hasClass('oe_kanban_global_click') || this.$el.hasClass('oe_kanban_global_click_edit')) { this.$el.on('click', this._onGlobalClick.bind(this)); + this.$el.on('keydown', this._onKeyDownCard.bind(this)); + } else { + this.$el.on('keydown', this._onKeyDownOpenFirstLink.bind(this)); } this._processFields(); this._processWidgets(); @@ -505,11 +507,33 @@ var KanbanRecord = Widget.extend({ this.do_warn("Kanban: no action for type : " + type); } }, - _onKanbanKeyDown: function (event) { + /** + * This event is linked to the kanban card when there is a global_click + * class on this card + * + * @private + * @param {KeyDownEvent} event + */ + _onKeyDownCard: function (event) { + switch (event.keyCode) { + case $.ui.keyCode.ENTER: + event.preventDefault(); + this._onGlobalClick(event); + break; + } + }, + /** + * This event is linked ot the kanban card when there is no global_click + * class on the card + * + * @private + * @param {KeyDownEvent} event + */ + _onKeyDownOpenFirstLink: function (event) { switch (event.keyCode) { case $.ui.keyCode.ENTER: event.preventDefault(); - this._openRecord(); + $(event.target).find('a, button').first().click(); break; } }, 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 4d26ba3627e..49fe72cc4b2 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 @@ -539,8 +539,13 @@ ListRenderer.include({ * @returns {Deferred} this deferred is resolved immediately */ _renderView: function () { + var self = this; this.currentRow = null; - return this._super.apply(this, arguments); + return this._super.apply(this, arguments).then(function () { + if (self._isEditable()) { + self.$('table').addClass('o_editable_list'); + } + }); }, /** * Force the resequencing of the items in the list. diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 4a5d9b45b4a..b45f7a9ffdf 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -17,6 +17,7 @@ odoo.define('web.PivotModel', function (require) { */ var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); var core = require('web.core'); var session = require('web.session'); var utils = require('web.utils'); @@ -32,6 +33,7 @@ var PivotModel = AbstractModel.extend({ this._super.apply(this, arguments); this.numbering = {}; this.data = null; + this._loadDataDropPrevious = new concurrency.DropPrevious(); }, //-------------------------------------------------------------------------- @@ -556,7 +558,7 @@ var PivotModel = AbstractModel.extend({ } } - return $.when.apply(null, groupBys.map(function (groupBy) { + return this._loadDataDropPrevious.add($.when.apply(null, groupBys.map(function (groupBy) { return self._rpc({ model: self.modelName, method: 'read_group', @@ -566,7 +568,7 @@ var PivotModel = AbstractModel.extend({ groupBy: groupBy, lazy: false, }); - })).then(function () { + }))).then(function () { var data = Array.prototype.slice.call(arguments); if (data[0][0].__count === 0) { self.data.has_data = false; diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 36bee4439c6..49cba9409cc 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -5,6 +5,14 @@ background-color: $o-view-background-color; margin-bottom: 0px; + // Checkbox in editable list + // should be clickable and activate the row + &.o_editable_list .o_data_row:not(.o_selected_row) .o_data_cell { + .o_checkbox:not(.o_readonly_modifier) { + pointer-events: none; + } + } + &.table { td, th { vertical-align: middle; diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index dfafdc85801..fd29e91c73c 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -12,6 +12,7 @@ var ListController = require('web.ListController'); var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin'); var RamStorage = require('web.RamStorage'); var ReportService = require('web.ReportService'); +var SessionStorageService = require('web.SessionStorageService'); var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); var createActionManager = testUtils.createActionManager; @@ -2860,6 +2861,68 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + QUnit.test("current act_window action is stored in session_storage", function (assert) { + assert.expect(1); + + var expectedAction = _.extend({}, _.findWhere(this.actions, {id: 3}), { + context: {}, + }); + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: [SessionStorageService.extend({ + setItem: function (key, value) { + assert.strictEqual(value, JSON.stringify(expectedAction), + "should store the executed action in the sessionStorage"); + }, + })], + }); + + actionManager.doAction(3); + + actionManager.destroy(); + }); + + QUnit.test("store evaluated context of current action in session_storage", function (assert) { + // this test ensures that we don't store stringified instances of + // CompoundContext in the session_storage, as they would be meaningless + // once restored + assert.expect(1); + + var expectedAction = _.extend({}, _.findWhere(this.actions, {id: 4}), { + context: { + active_model: 'partner', + active_id: 1, + active_ids: [1], + }, + }); + var checkSessionStorage = false; + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: [SessionStorageService.extend({ + setItem: function (key, value) { + if (checkSessionStorage) { + assert.strictEqual(value, JSON.stringify(expectedAction), + "should correctly store the executed action in the sessionStorage"); + } + }, + })], + }); + + // execute an action and open a record in form view + actionManager.doAction(3); + actionManager.$('.o_list_view .o_data_row:first').click(); + + // click on 'Execute action' button (it executes an action with a CompoundContext as context) + checkSessionStorage = true; + actionManager.$('.o_form_view button:contains(Execute action)').click(); + + actionManager.destroy(); + }); + QUnit.module('Actions in target="new"'); QUnit.test('can execute act_window actions in target="new"', function (assert) { diff --git a/addons/web/static/tests/chrome/keyboard_navigation_mixin_tests.js b/addons/web/static/tests/chrome/keyboard_navigation_mixin_tests.js new file mode 100644 index 00000000000..38f13e7a117 --- /dev/null +++ b/addons/web/static/tests/chrome/keyboard_navigation_mixin_tests.js @@ -0,0 +1,31 @@ +odoo.define('web.keyboard_navigation_mixin_tests', function (require) { +"use strict"; + +var KeyboardNavigationMixin = require('web.KeyboardNavigationMixin'); +var Widget = require('web.Widget'); + +QUnit.module('KeyboardNavigationMixin', function () { + QUnit.test('aria-keyshortcuts is added on elements with accesskey', function (assert) { + assert.expect(1); + var $target = $('#qunit-fixture'); + var KeyboardWidget = Widget.extend(KeyboardNavigationMixin, { + start: function () { + var $button = $('<button>').text('Click Me!').attr('accesskey', 'o'); + // we need to define the accesskey because it will not be assigned on invisible buttons + this.$el.append($button); + return this._super.apply(this, arguments); + }, + }); + var w = new KeyboardWidget(); + w.appendTo($target); + + // minimum set of attribute to generate a native event that works with the mixin + var e = new Event("keydown"); + e.key = ''; + e.altKey = true; + w.$el[0].dispatchEvent(e); + + assert.ok(w.$el.find('button[aria-keyshortcuts]')[0], 'the aria-keyshortcuts is set on the button'); + }); +}); +}); diff --git a/addons/web/static/tests/core/pyeval_tests.js b/addons/web/static/tests/core/pyeval_tests.js index 89bfa32872b..737466c8c93 100644 --- a/addons/web/static/tests/core/pyeval_tests.js +++ b/addons/web/static/tests/core/pyeval_tests.js @@ -508,7 +508,6 @@ QUnit.module('core', function () { { "default_attachment_ids": [], "default_body": "", - "default_content_subtype": "html", "default_model": "res.users", "default_parent_id": false, "default_res_id": 1 @@ -526,7 +525,6 @@ QUnit.module('core', function () { assert.deepEqual(result.context, { "default_attachment_ids": [], "default_body": "", - "default_content_subtype": "html", "default_model": "res.users", "default_parent_id": false, "default_res_id": 1, diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index b16d812993e..1012974bb58 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -599,6 +599,35 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('many2one in non edit mode', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="trululu"/>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('a.o_form_uri').length, 1, + "should display 1 m2o link in form"); + assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#id=4&model=partner", + "href should contain id and model"); + + // Remove value from many2one and then save, there should not have href with id and model on m2o anchor + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_many2one input').val('').trigger('keyup').trigger('focusout'); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#", + "href should have #"); + + form.destroy(); + }); + QUnit.test('many2one searches with correct value', function (assert) { var done = assert.async(); assert.expect(6); @@ -1208,8 +1237,167 @@ QUnit.module('relational_fields', { form.destroy(); }); - QUnit.test('list in form: discard newly added element with empty required field (onchange)', function (assert) { - assert.expect(8); + QUnit.test('list in form: name_get with unique ids (default_get)', function (assert) { + assert.expect(2); + + this.data.partner.records[0].display_name = "MyTrululu"; + + 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) { + if (args.method === 'default_get') { + return $.when({ + p: [ + [0, 0, { trululu: 1 }], + [0, 0, { trululu: 1 }] + ] + }); + } + if (args.method === 'name_get') { + assert.deepEqual(args.args[0], _.uniq(args.args[0]), + "should not have duplicates in name_get rpc"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('td.o_data_cell').text(), "MyTrululuMyTrululu", + "both records should have the correct display_name for trululu field"); + + form.destroy(); + }); + + QUnit.test('list in form: show name of many2one fields in multi-page (default_get)', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="p">' + + '<tree editable="bottom" limit="1">' + + '<field name="display_name"/>' + + '<field name="trululu"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + p: [ + [0, 0, { display_name: 'record1', trululu: 1 }], + [0, 0, { display_name: 'record2', trululu: 2 }] + ] + }); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('td.o_data_cell').first().text(), + "record1", "should show display_name of 1st record"); + assert.strictEqual(form.$('td.o_data_cell').first().next().text(), + "first record", "should show display_name of trululu of 1st record"); + + form.$('button.o_pager_next').click(); + + assert.strictEqual(form.$('td.o_data_cell').first().text(), + "record2", "should show display_name of 2nd record"); + assert.strictEqual(form.$('td.o_data_cell').first().next().text(), + "second record", "should show display_name of trululu of 2nd record"); + + form.destroy(); + }); + + QUnit.test('list in form: discard newly added element with empty required field (onchange in default_get)', function (assert) { + // variant of the test "list in form: discard newly added element with + // empty required field (default_get)", in which the `default_get` + // performs an `onchange` at the same time. This `onchange` may create + // some records, which should not be abandoned on discard, similarly + // to records created directly by `default_get` + assert.expect(7); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + this.data.partner.onchanges = { + product_id: function (obj) { + if (obj.product_id === 37) { + obj.p = [[0, 0, { display_name: "entry", trululu: false }]]; + } + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="product_id"/>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '<field name="trululu" required="1"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + product_id: 37, + }); + } + return this._super.apply(this, arguments); + }, + }); + + // check that there is a record in the editable list with empty string as required field + assert.strictEqual(form.$('.o_data_row').length, 1, + "should have a row in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should have the correct displayed name"); + var requiredField = $('td.o_data_cell.o_required_modifier'); + assert.strictEqual(requiredField.length, 1, + "should have a required field on this record"); + assert.strictEqual(requiredField.text(), "", + "should have empty string in the required field on this record"); + + // click on empty required field in editable list record + requiredField.click(); + // click off so that the required field still stay empty + $('body').click(); + + // record should not be dropped + assert.strictEqual(form.$('.o_data_row').length, 1, + "should not have dropped record in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should still have the correct displayed name"); + assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "", + "should still have empty string in the required field"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + }); + + QUnit.test('list in form: discard newly added element with empty required field (onchange after default_get)', function (assert) { + // discarding a record from an `onchange` in a `default_get` should not + // abandon the record. However, any `onchange` after `default_get` + // should be abandoned on discard + assert.expect(6); var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; @@ -1220,7 +1408,6 @@ QUnit.module('relational_fields', { obj.p = [[0, 0, { display_name: "entry", trululu: false }]]; } }, - trululu: function () {}, }; var form = createView({ @@ -1262,13 +1449,9 @@ QUnit.module('relational_fields', { // click off so that the required field still stay empty $('body').click(); - // record should not be dropped - assert.strictEqual(form.$('.o_data_row').length, 1, - "should not have droppped record in the editable list"); - assert.strictEqual($('td.o_data_cell').first().text(), "entry", - "should still have the correct displayed name"); - assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "", - "should still have empty string in the required field"); + // record should be dropped + assert.strictEqual(form.$('.o_data_row').length, 0, + "should have dropped record in the editable list"); relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; form.destroy(); @@ -2243,6 +2426,43 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('x2many list sorted by many2one', function (assert) { + assert.expect(3); + + this.data.partner.records[0].p = [1, 2, 4]; + this.data.partner.fields.trululu.sortable = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="p">' + + '<tree>' + + '<field name="id"/>' + + '<field name="trululu"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '124', + "should have correct order initially"); + + form.$('.o_list_view thead th:nth(1)').click(); + + assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '412', + "should have correct order (ASC)"); + + form.$('.o_list_view thead th:nth(1)').click(); + + assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '214', + "should have correct order (DESC)"); + + form.destroy(); + }); + QUnit.test('many2many list add *many* records, remove, re-add', function (assert) { assert.expect(5); @@ -5763,7 +5983,7 @@ QUnit.module('relational_fields', { rpcCount++; if (args.method === 'write') { assert.deepEqual(args.args[1].p, [[0, args.args[1].p[0][1], { - int_field: 123, product_id: 41 + foo: false, int_field: 123, product_id: 41, }]]); } return this._super(route, args); @@ -8921,6 +9141,74 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many form view with action button', function (assert) { + // once the action button is clicked, the record is reloaded (via the + // on_close handler, executed because the python method does not return + // any action, or an ir.action.act_window_close) ; this test ensures that + // it reloads the fields of the opened view (i.e. the form in this case). + // See https://github.com/odoo/odoo/issues/24189 + assert.expect(7); + + var data = this.data; + data.partner.records[0].p = [2]; + + var form = createView({ + View: FormView, + model: 'partner', + data: data, + res_id: 1, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree><field name="display_name"/></tree>' + + '<form>' + + '<button type="action" string="Set Timmy"/>' + + '<field name="timmy"/>' + + '</form>' + + '</field>' + + '</form>', + archs: { + 'partner_type,false,list': '<tree><field name="display_name"/></tree>', + }, + intercepts: { + execute_action: function (ev) { + data.partner.records[1].display_name = 'new name'; + data.partner.records[1].timmy = [12]; + ev.data.on_closed(); + }, + }, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "there should be one record in the one2many"); + assert.strictEqual(form.$('.o_data_cell').text(), 'second record', + "initial display_name of o2m record should be correct"); + + // open one2many record in form view + form.$('.o_data_cell:first').click(); + assert.strictEqual($('.modal .o_form_view').length, 1, + "should have opened the form view in a dialog"); + assert.strictEqual($('.modal .o_form_view .o_data_row').length, 0, + "there should be no record in the many2many"); + + // click on the action button + $('.modal .o_form_view button').click(); + assert.strictEqual($('.modal .o_data_row').length, 1, + "fields in the o2m form view should have been read"); + assert.strictEqual($('.modal .o_data_cell').text(), 'gold', + "many2many subrecord should have been fetched"); + + // save the dialog + $('.modal .modal-footer .btn-primary').click(); + + assert.strictEqual(form.$('.o_data_cell').text(), 'new name', + "fields in the o2m list view should have been read as well"); + + form.destroy(); + }); + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index b57b59eb2a7..5afdc3dd447 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -116,12 +116,12 @@ 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) { + }, function (result, event) { var errorString = JSON.stringify(result || false); if (debug) { console.log('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString)); } - return JSON.parse(errorString); + return $.Deferred().reject(errorString, event || $.Event()); }); }, @@ -854,7 +854,7 @@ var MockServer = Class.extend({ */ _mockReadProgressBar: function (model, kwargs) { var domain = kwargs.domain; - var groupBy = kwargs.groupBy; + var groupBy = kwargs.group_by; var progress_bar = kwargs.progress_bar; var records = this._getRecords(model, domain || []); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 2da3eb5ddd9..6c05076ef98 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -733,53 +733,6 @@ function patch (target, props) { }); } } -/** - * In Phantomjs, there is a crash when calling window.getSelection - * in order for the tests to work, for the specific test that uses it, replace - * the default window.getSelection by a mock - * - * usage: - * QUnit.test('...',function(done){ - * var unpatchWindowGetSelection = testUtils.patchWindowGetSelection(); - * - * // do something that needs to use window.getSelection() - * assert.strictEqual(....); - * - * // restore the original function - * unpatchWindowGetSelection(); - * - * // finish the test - * done(); - * }) - * - * @returns {function} the unpatch function - */ -function patchWindowGetSelection() { - var originalWindowGetSelection = window.getSelection; - window.getSelection = function () { - return { - removeAllRanges: function () {}, - addRange: function (range) {}, - getRangeAt: function (index) { - return { - startOffset : 0 - }; - }, - anchorNode: { - parentNode: { - childNodes: [{ - outerHTML: "@", - nodeType: 3, - textContent: '@', - }], - }, - }, - }; - }; - return function () { - window.getSelection = originalWindowGetSelection; - }; -} /** * Unpatches a given Class or Object. @@ -831,7 +784,6 @@ return $.when( intercept: intercept, observe: observe, patch: patch, - patchWindowGetSelection: patchWindowGetSelection, removeSrcAttribute: removeSrcAttribute, triggerKeypressEvent: triggerKeypressEvent, triggerMouseEvent: triggerMouseEvent, diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index f1356558a23..9728d8a7c74 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -1895,6 +1895,101 @@ QUnit.module('Views', { calendar.destroy(); }); + + QUnit.test('form_view_id attribute works (for creating events)', function (assert) { + assert.expect(1); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month" '+ + 'form_view_id="42">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + // we simulate here the case where a create call with just + // the field name fails. This is a normal flow, the server + // reject the create rpc (quick create), then the web client + // fall back to a form view. This happens typically when a + // model has required fields + return $.Deferred().reject('None shall pass!'); + } + return this._super(route, args); + }, + intercepts: { + do_action: function (event) { + assert.strictEqual(event.data.action.views[0][0], 42, + "should do a do_action with view id 42"); + }, + }, + }); + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + var $input = $('.modal-body input:first'); + $input.val("It's just a fleshwound").trigger('input'); + $('.modal button.btn:contains(Create)').trigger('click'); + + calendar.destroy(); + }); + + QUnit.test('calendar fallback to form view id in action if necessary', function (assert) { + assert.expect(1); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month"> '+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + action: {views: [[1, 'kanban'], [43, 'form']]} + }, + mockRPC: function (route, args) { + if (args.method === "create") { + // we simulate here the case where a create call with just + // the field name fails. This is a normal flow, the server + // reject the create rpc (quick create), then the web client + // fall back to a form view. This happens typically when a + // model has required fields + return $.Deferred().reject('None shall pass!'); + } + return this._super(route, args); + }, + intercepts: { + do_action: function (event) { + assert.strictEqual(event.data.action.views[0][0], 43, + "should do a do_action with view id 43"); + }, + }, + }); + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + var $input = $('.modal-body input:first'); + $input.val("It's just a fleshwound").trigger('input'); + $('.modal button.btn:contains(Create)').trigger('click'); + + calendar.destroy(); + }); + }); }); diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index a13130e4b27..028eb4cb3a3 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -602,6 +602,22 @@ QUnit.module('Views', { done(); }); }); + + QUnit.test('graph view crash when moving from search view using Down key', function (assert) { + assert.expect(1); + + var graph = createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="pie">' + + '<field name="bar"/>' + + '</graph>', + }); + graph.renderer.giveFocus(); + assert.ok(true,"should not generate any error"); + graph.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 04003c568a3..04c1c603118 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -3273,6 +3273,132 @@ QUnit.module('Views', { kanban.destroy(); }); + // XXX test deactivated in saas~11.2 as kanban archiving has been removed (and readded in saas~11.4) + QUnit.skip('column progressbars on archiving records update counter', function (assert) { + assert.expect(4); + + // add active field on partner model and make all records active + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="active"/>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "36", + "counter should contain the correct value"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "1 yop", + "the counter progressbars should be correctly displayed"); + + // archive all records of the second columns + kanban.$('.o_kanban_group:eq(1) .o_column_archive').click(); + + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "0", + "counter should contain the correct value"); + assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "0 yop", + "the counter progressbars should have been correctly updated"); + + kanban.destroy(); + }); + + QUnit.skip('kanban with progressbars: correctly update env when archiving records', function (assert) { + assert.expect(3); + + // add active field on partner model and make all records active + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="active"/>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + intercepts: { + env_updated: function (ev) { + assert.step(ev.data.ids); + }, + }, + }); + + // archive all records of the first column + kanban.$('.o_kanban_group:first .o_column_archive').click(); + + assert.verifySteps([ + [1, 2, 3, 4], + [1, 2, 3], + ]); + + kanban.destroy(); + }); + + QUnit.test('RPCs when (re)loading kanban view progressbars', function (assert) { + assert.expect(9); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + kanban.reload(); + + assert.verifySteps([ + // initial load + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read_progress_bar', + // reload + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read_progress_bar', + ]); + + kanban.destroy(); + }); + QUnit.test('drag & drop records grouped by m2o with progressbar', function (assert) { assert.expect(4); @@ -3342,7 +3468,7 @@ QUnit.module('Views', { "Initial count should be Three"); kanban.$('.bg-success-full').click(); var lastCount = parseInt(kanban.$('.o_kanban_counter_side').eq(1).text()); - assert.strictEqual(lastCount, 0, + assert.strictEqual(lastCount, 1, "kanban counters should vary according to what subgroup is selected"); kanban.destroy(); @@ -3638,6 +3764,34 @@ QUnit.module('Views', { kanban.destroy(); }); + + QUnit.test('keyboard navigation on kanban when the focus is on a link that' + + 'has an action and the kanban has no oe_kanban_global_... class', function(assert){ + assert.expect(1) + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div><a type="edit">Edit</a></div>' + + '</t></templates></kanban>', + }); + + testUtils.intercept(kanban, 'switch_view', function (event) { + assert.deepEqual(event.data, { + view_type: 'form', + res_id: 1, + mode: 'edit', + model: 'partner', + },'When selecting focusing a card and hitting ENTER, the first link or button is clicked'); + }); + kanban.$('.o_kanban_record').first().focus().trigger($.Event('keydown', { + keyCode: $.ui.keyCode.ENTER, + which: $.ui.keyCode.ENTER, + })); + + kanban.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index c948528dcc6..fb4ccee9f8b 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2414,7 +2414,7 @@ QUnit.module('Views', { // Press 'Tab' -> should go to next line // add a value in the cell because the Tab on an empty first cell would activate the next widget in the view - list.$('.o_selected_row input').val(11).trigger('input'); + list.$('.o_selected_row input').val(11).trigger('input'); list.$('.o_selected_row input').trigger({type: 'keydown', which: 9}); assert.ok(list.$('.o_data_row:nth(3)').hasClass('o_selected_row'), "fourth row should be in edition"); @@ -3431,6 +3431,47 @@ QUnit.module('Views', { testUtils.unpatch(mixins.ParentedMixin); }); + QUnit.test('concurrent reloads finishing in inverse order', function (assert) { + assert.expect(3); + + var blockSearchRead = false; + var def = $.Deferred(); + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="foo"/></tree>', + mockRPC: function (route) { + var result = this._super.apply(this, arguments); + if (route === '/web/dataset/search_read' && blockSearchRead) { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + assert.strictEqual(list.$('.o_list_view .o_data_row').length, 4, + "list view should contain 4 records"); + + // reload with a domain (this request is blocked) + blockSearchRead = true; + list.reload({domain: [['foo', '=', 'yop']]}); + + assert.strictEqual(list.$('.o_list_view .o_data_row').length, 4, + "list view should still contain 4 records (search_read being blocked)"); + + // reload without the domain + blockSearchRead = false; + list.reload({domain: []}); + + // unblock the RPC + def.resolve(); + assert.strictEqual(list.$('.o_list_view .o_data_row').length, 4, + "list view should still contain 4 records"); + + list.destroy(); + }); + QUnit.test('list view on a "noCache" model', function (assert) { assert.expect(8); diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index a1d9e1fae1f..4d7358f2574 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -1145,4 +1145,37 @@ QUnit.module('Views', { pivot.destroy(); }); + + QUnit.test('parallel data loading should discard all but the last one', function (assert) { + assert.expect(2); + + var def; + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'read_group') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + def = $.Deferred(); + pivot.update({groupBy: ['product_id']}); + pivot.update({groupBy: ['product_id', 'customer']}); + def.resolve(); + + assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 6, + "should have 6 cells"); + assert.strictEqual(pivot.$('tbody tr').length, 6, + "should have 6 rows"); + pivot.destroy(); + }); });}); |
