diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-09-20 17:33:22 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-09-20 17:34:45 +0200 |
| commit | 64e43808b79badb5869a8d73cc1cc3f3b3aa8e12 (patch) | |
| tree | 9a22d5f6c03073a4d10faedea8df23a5211e8b8f /addons/web/static | |
| parent | 944891ced081cda3b8b3db5faf3b7ac23b6b4ce8 (diff) | |
| parent | 58a83d12228fbbd4e9dde542e133d264204ee0f3 (diff) | |
[MERGE] forward port branch saas-12.5 up to 58a83d12228
Diffstat (limited to 'addons/web/static')
46 files changed, 2078 insertions, 277 deletions
diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index 1a08bf59a9a..6027e21bb42 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -697,7 +697,7 @@ ActionManager.include({ }; } var options = {on_close: ev.data.on_closed}; - action.flags = _.extend({}, action.flags, {withSearchPanel: false}); + action.flags = _.extend({}, action.flags, {searchPanelDefaultNoFilter: true}); return self.doAction(action, options).then(ev.data.on_success, ev.data.on_fail); }); }, diff --git a/addons/web/static/src/js/chrome/user_menu.js b/addons/web/static/src/js/chrome/user_menu.js index 3b93bbd9c3b..ffc23e53273 100644 --- a/addons/web/static/src/js/chrome/user_menu.js +++ b/addons/web/static/src/js/chrome/user_menu.js @@ -46,7 +46,7 @@ var UserMenu = Widget.extend({ self.$('.oe_topbar_name').text(topbar_name); var avatar_src = session.url('/web/image', { model:'res.users', - field: 'image_64', + field: 'image_128', id: session.uid, }); $avatar.attr('src', avatar_src); diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 7be17700f98..f260478da1e 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -108,16 +108,13 @@ var dom = { class: $textarea[0].className, }); - var direction = _t.database.parameters.direction === 'rtl' ? 'right' : 'left'; $fixedTextarea.css({ - position: 'absolute', opacity: 0, - height: 10, + height: 0, borderTopWidth: 0, borderBottomWidth: 0, padding: 0, - top: -10000, - }).css(direction, -10000); + }); $fixedTextarea.data("auto_resize", true); // The following line is necessary to prevent the scrollbar to appear diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index da46488cffa..c1d01022c68 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -684,6 +684,9 @@ var FieldDate = InputField.extend({ className: "o_field_date", tagName: "span", supportedFieldTypes: ['date', 'datetime'], + // we don't need to listen on 'input' nor 'change' events because the + // datepicker widget is already listening, and will correctly notify changes + events: AbstractField.prototype.events, /** * @override @@ -1080,6 +1083,8 @@ var FieldBoolean = AbstractField.extend({ _onKeydown: function (ev) { switch (ev.which) { case $.ui.keyCode.ENTER: + // prevent subsequent 'click' event (see _onKeydown of AbstractField) + ev.preventDefault(); this.$input.prop('checked', !this.value); this._setValue(!this.value); return; @@ -2545,11 +2550,28 @@ var FieldProgressBar = AbstractField.extend({ if (this.recordData[this.nodeOptions.current_value]) { this.value = this.recordData[this.nodeOptions.current_value]; } - this.max_value = this.recordData[this.nodeOptions.max_value] || 100; + + // The few next lines determine if the widget can write on the record or not + this.editable_readonly = !!this.nodeOptions.editable_readonly; + // "hard" readonly this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable; - this.edit_max_value = this.nodeOptions.edit_max_value || false; + + this.canWrite = !this.readonly && ( + this.mode === 'edit' || + (this.editable_readonly && this.mode === 'readonly') || + (this.viewType === 'kanban') // Keep behavior before commit + ); + + // Boolean to toggle if we edit the numerator (value) or the denominator (max_value) + this.edit_max_value = !!this.nodeOptions.edit_max_value; + this.max_value = this.recordData[this.nodeOptions.max_value] || 100; + this.title = _t(this.attrs.title || this.nodeOptions.title) || ''; - this.edit_on_click = !this.nodeOptions.edit_max_value || false; + + // Ability to edit the field through the bar + // /!\ this feature is disabled + this.enableBarAsInput = false; + this.edit_on_click = this.enableBarAsInput && this.mode === 'readonly' && !this.edit_max_value; this.write_mode = false; }, @@ -2557,19 +2579,19 @@ var FieldProgressBar = AbstractField.extend({ var self = this; this._render_value(); - if (!this.readonly) { + if (this.canWrite) { if (this.edit_on_click) { this.$el.on('click', '.o_progress', function (e) { var $target = $(e.currentTarget); - self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value); + var numValue = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value); + self.on_update(numValue); self._render_value(); - self.on_update(self.value); }); } else { this.$el.on('click', function () { if (!self.write_mode) { var $input = $('<input>', {type: 'text', class: 'o_progressbar_value o_input'}); - $input.on('blur', _.bind(self.on_change_input, self)); + $input.on('blur', self.on_change_input.bind(self)); self.$('.o_progressbar_value').replaceWith($input); self.write_mode = true; self._render_value(); @@ -2579,24 +2601,25 @@ var FieldProgressBar = AbstractField.extend({ } return this._super(); }, + /** + * Updates the widget with value + * + * @param {Number} value + */ on_update: function (value) { - if (!isNaN(value)) { - if (this.edit_max_value) { - try { - this.max_value = this._parseValue(value); - this._isValid = true; - } catch (e) { - this._isValid = false; - } - var changes = {}; - changes[this.nodeOptions.max_value] = this.max_value; - this.trigger_up('field_changed', { - dataPointID: this.dataPointID, - changes: changes, - }); - } else { - this._setValue(value); - } + if (this.edit_max_value) { + this.max_value = value; + this._isValid = true; + var changes = {}; + changes[this.nodeOptions.max_value] = this.max_value; + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: changes, + }); + } else { + // _setValues accepts string and will parse it + var formattedValue = this._formatValue(value); + this._setValue(formattedValue); } }, on_change_input: function (e) { @@ -2604,29 +2627,42 @@ var FieldProgressBar = AbstractField.extend({ if (e.type === 'change' && !$input.is(':focus')) { return; } - if (isNaN($input.val())) { - this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid.")); - } else { - if (e.type === 'input') { - this._render_value($input.val()); - if (parseFloat($input.val()) === 0) { + + var parsedValue; + try { + // Cover all numbers with parseFloat + parsedValue = field_utils.parse.float($input.val()); + } catch (error) { + this.do_warn(_t("Wrong value entered!"), _t("Only Integer or Float Value should be valid.")); + } + + if (parsedValue !== undefined) { + if (e.type === 'input') { // ensure what has just been typed in the input is a number + // returns NaN if not a number + this._render_value(parsedValue); + if (parsedValue === 0) { $input.select(); } - } else { + } else { // Implicit type === 'blur': we commit the value if (this.edit_max_value) { - this.max_value = $(e.target).val(); - } else { - this.value = $(e.target).val() || 0; + parsedValue = parsedValue || 100; } + var $div = $('<div>', {class: 'o_progressbar_value'}); this.$('.o_progressbar_value').replaceWith($div); this.write_mode = false; + this.on_update(parsedValue); this._render_value(); - this.on_update(this.edit_max_value ? this.max_value : this.value); } } }, + /** + * Renders the value + * + * @private + * @param {Number} v + */ _render_value: function (v) { var value = this.value; var max_value = this.max_value; diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index b2d3379aef1..b89900c9b3b 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -397,8 +397,10 @@ var FieldMany2One = AbstractField.extend({ kanban_view_ref: this.attrs.kanban_view_ref, on_selected: function (records) { self.reinitialize(records[0]); + }, + on_closed: function () { self.activate(); - } + }, }; }, /** @@ -650,7 +652,6 @@ var FieldMany2One = AbstractField.extend({ * in the dialog (each filter has keys 'description' and 'domain') */ _searchCreatePopup: function (view, ids, context, dynamicFilters) { - var self = this; var options = this._getSearchCreatePopupOptions(view, ids, context, dynamicFilters); return new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, options)).open(); }, @@ -820,6 +821,46 @@ var Many2oneBarcode = FieldMany2One.extend({ }); var ListFieldMany2One = FieldMany2One.extend({ + events: _.extend({}, FieldMany2One.prototype.events, { + 'focusin input': '_onInputFocusin', + }), + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + // when we empty the input, we delay the setValue to prevent from + // triggering the 'fieldChanged' event twice when the user wants set + // another m2o value ; the following attribute is used to determine when + // we skipped the setValue, s.t. we can perform it later on if the user + // didn't select another value + this.mustSetValue = false; + this.m2oDialogFocused = false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * If in readonly, will never be considered as an active widget. + * + * @override + */ + activate: function () { + if (this.mode === 'readonly') { + return false; + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + reinitialize: function () { + this.mustSetValue = false; + return this._super.apply(this, arguments); + }, //-------------------------------------------------------------------------- // Private @@ -831,6 +872,52 @@ var ListFieldMany2One = FieldMany2One.extend({ _renderReadonly: function () { this.$el.text(this.m2o_value); }, + /** + * @override + * @private + */ + _searchCreatePopup: function () { + this.m2oDialogFocused = true; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInputFocusin: function () { + this.m2oDialogFocused = false; + }, + /** + * In list views, we don't want to try to trigger a fieldChange when the field + * is being emptied. Instead, it will be triggered as the user leaves the field + * while it is empty. + * + * @override + * @private + */ + _onInputFocusout: function () { + this._super.apply(this, arguments); + if (!this.m2oDialogFocused && this.$input.val() === "" && this.mustSetValue) { + this.reinitialize(false); + } + }, + /** + * Prevents the triggering of an immediate _onFieldChanged when emptying the field. + * + * @override + * @private + */ + _onInputKeyup: function () { + if (this.$input.val() !== "") { + this._super.apply(this, arguments); + } else { + this.mustSetValue = true; + } + }, }); var KanbanFieldMany2One = AbstractField.extend({ @@ -2199,13 +2286,16 @@ var FieldMany2ManyTags = AbstractField.extend({ this.many2one._getSearchCreatePopupOptions = function (view, ids, context, dynamicFilters) { var options = _getSearchCreatePopupOptions.apply(this, arguments); var domain = this.record.getDomain({fieldName: this.name}); + var m2mRecords = []; return _.extend({}, options, { domain: domain.concat(["!", ["id", "in", self.value.res_ids]]), disable_multiple_selection: false, on_selected: function (records) { - self.many2one.reinitialize(records); - self.many2one.activate(); - } + m2mRecords.push(...records); + }, + on_closed: function () { + self.many2one.reinitialize(m2mRecords); + }, }); }; return this.many2one.appendTo(this.$el); @@ -2291,7 +2381,7 @@ var FieldMany2ManyTagsAvatar = FieldMany2ManyTags.extend({ _getRenderTagsContext: function () { var result = this._super.apply(this, arguments); result.avatarModel = this.nodeOptions.avatarModel || this.field.relation; - result.avatarField = this.nodeOptions.avatarField || 'image_64'; + result.avatarField = this.nodeOptions.avatarField || 'image_128'; return result; }, }); diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index cb83301c14d..43a485fb009 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -19,6 +19,8 @@ var config = require('web.config'); var core = require('web.core'); var mvc = require('web.mvc'); +var session = require('web.session'); + var QWeb = core.qweb; var AbstractController = mvc.Controller.extend(ActionMixin, { @@ -519,6 +521,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { this.dp.add(this._rpc({ model: data.model, method: data.method, + context: session.user_context, })).then(function (action) { if (action !== undefined) { self.do_action(action, options); diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index d581f84308a..7dc3cc30563 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -91,8 +91,8 @@ var AbstractView = Factory.extend({ * @param {Array[]} [params.searchQuery.domain=[]] * @param {string[]} [params.searchQuery.groupBy=[]] * @param {Object} [params.userContext={}] - * @param {boolean} [params.withControlPanel=true] - * @param {boolean} [params.withSearchPanel=true] + * @param {boolean} [params.withControlPanel=AbstractView.prototype.withControlPanel] + * @param {boolean} [params.withSearchPanel=AbstractView.prototype.withSearchPanel] */ init: function (viewInfo, params) { this._super.apply(this, arguments); @@ -115,7 +115,9 @@ var AbstractView = Factory.extend({ this.fields = this.fieldsView.viewFields; this.userContext = params.userContext || {}; this.withControlPanel = this.withControlPanel && params.withControlPanel; - this.withSearchPanel = this.withSearchPanel && this.multi_record && params.withSearchPanel; + const searchPanelDisabled = 'search_panel' in params.context && !params.search_panel; + this.withSearchPanel = this.withSearchPanel && this.multi_record && + params.withSearchPanel && !searchPanelDisabled; // the boolean parameter 'isEmbedded' determines if the view should be // considered as a subview. For now this is only used by the graph @@ -187,6 +189,9 @@ var AbstractView = Factory.extend({ withSearchBar: params.withSearchBar, }; this.searchPanelParams = { + defaultNoFilter: params.searchPanelDefaultNoFilter, + fields: this.fields, + model: this.loadParams.modelName, state: controllerState.spState, }; }, @@ -292,15 +297,12 @@ var AbstractView = Factory.extend({ } }); var controlPanelDomain = this.loadParams.domain; - var searchPanel = new this.config.SearchPanel(parent, { + var spParams = _.extend({}, this.searchPanelParams, { defaultValues: defaultValues, - fields: this.fields, - model: this.loadParams.modelName, searchDomain: controlPanelDomain, - sections: this.searchPanelParams.sections, - state: this.searchPanelParams.state, classes: params.classes || [], }); + var searchPanel = new this.config.SearchPanel(parent, spParams); this.controllerParams.searchPanel = searchPanel; this.controllerParams.controlPanelDomain = controlPanelDomain; await searchPanel.appendTo(document.createDocumentFragment()); 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 2d239520dfb..4b4d02c2755 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1199,7 +1199,7 @@ var BasicModel = AbstractModel.extend({ if (!_.isEmpty(action)) { return self.do_action(action, { on_close: function () { - return self.reload(parentID); + return self.trigger_up('reload'); } }); } else { @@ -1231,7 +1231,7 @@ var BasicModel = AbstractModel.extend({ if (!_.isEmpty(action)) { return self.do_action(action, { on_close: function () { - return self.reload(parentID); + return self.trigger_up('reload'); } }); } else { @@ -1263,7 +1263,7 @@ var BasicModel = AbstractModel.extend({ if (!_.isEmpty(action)) { return self.do_action(action, { on_close: function () { - return self.reload(parentID); + return self.trigger_up('reload'); } }); } else { 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 232a7316fa5..1b081316f20 100644 --- a/addons/web/static/src/js/views/calendar/calendar_view.js +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -105,7 +105,7 @@ var CalendarView = AbstractView.extend({ }; if (fields[fieldName].relation) { if (['res.users', 'res.partner', 'hr.employee'].indexOf(fields[fieldName].relation) !== -1) { - filters[fieldName].avatar_field = 'image_64'; + filters[fieldName].avatar_field = 'image_128'; } filters[fieldName].avatar_model = fields[fieldName].relation; } diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js index 677f0733bc5..bdde2827e96 100644 --- a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js +++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js @@ -50,6 +50,7 @@ var ControlPanelRenderer = Renderer.extend({ this.$subMenus = null; this.action = params.action; this.displaySearchMenu = true; + this.isMobile = config.device.isMobile; this.menusSetup = false; this.searchMenuTypes = params.searchMenuTypes || []; this.subMenus = {}; diff --git a/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js index 23852abcf53..95b5c577587 100644 --- a/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js +++ b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js @@ -9,9 +9,9 @@ if (!config.device.isMobile) { } ControlPanelRenderer.include({ - template:'MobileControlPanel', events:_.extend({}, ControlPanelRenderer.prototype.events, { - 'click .o_mobile_search_close, .o_mobile_search_show_result, .o_enable_searchview': '_toggleMobileSearchView', + 'click .o_mobile_search_close, .o_mobile_search_show_result, .o_toggle_searchview_full': '_toggleMobileSearchView', + 'click .o_enable_searchview': '_toggleMobileQuickSearchView', 'click': '_onOpenMobileSearchView', 'click .o_mobile_search_clear_facets': '_onEmptyAll', 'show.bs.dropdown .o_mobile_search_filter .o_dropdown': '_onDropdownToggle', @@ -49,17 +49,40 @@ ControlPanelRenderer.include({ _renderSearchBar: function () { var self = this; return this._super.apply(this, arguments).then(function () { - self.searchBar.$el.detach().insertAfter(self.$('.o_mobile_search_header')); + self._renderSearchviewInput(); }); }, + _renderSearchviewInput: function () { + if (this.$('.o_toggle_searchview_full').is(':visible') && !this.$('.o_mobile_search').is(':visible')) { + this.$('.o_toggle_searchview_full').toggleClass('btn-secondary', !!this.state.query.length); + this.searchBar.$el.detach().insertAfter(this.$('.o_mobile_search')); + } else { + this.searchBar.$el.detach().insertAfter(this.$('.o_mobile_search_header')); + } + }, /** * Toggles mobile search view screen. * * @private */ _toggleMobileSearchView: function () { - this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.state.query.length); this.$('.o_mobile_search').toggleClass('o_hidden'); + this._renderSearchviewInput(); + }, + /** + * Toggles mobile quick search view on screen. + * + * @private + */ + _toggleMobileQuickSearchView: function () { + this.$('.o_cp_searchview').toggleClass('o_searchview_quick'); + this.$('.breadcrumb').toggleClass('o_hidden', + this.$('.o_cp_searchview').hasClass('o_searchview_quick')); + this.$('.o_toggle_searchview_full') + .toggleClass('o_hidden') + .toggleClass('btn-secondary', !!this.state.query.length); + this._renderSearchviewInput(); + this.$('.o_enable_searchview').toggleClass("fa-search").toggleClass("fa-close"); }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar.js b/addons/web/static/src/js/views/control_panel/search/search_bar.js index 3f58fcdce2a..42b156beed7 100644 --- a/addons/web/static/src/js/views/control_panel/search/search_bar.js +++ b/addons/web/static/src/js/views/control_panel/search/search_bar.js @@ -9,6 +9,8 @@ var Widget = require('web.Widget'); var SearchBar = Widget.extend({ template: 'SearchView.SearchBar', events: _.extend({}, Widget.prototype.events, { + 'compositionend .o_searchview_input': '_onCompositionendInput', + 'compositionstart .o_searchview_input': '_onCompositionstartInput', 'keydown': '_onKeydown', }), /** @@ -32,6 +34,7 @@ var SearchBar = Widget.extend({ this.autoCompleteSources = []; this.searchFacets = []; + this._isInputComposing = false; }, /** * @override @@ -200,10 +203,27 @@ var SearchBar = Widget.extend({ } }, /** + * @rivate + * @param {CompositionEvent} ev + */ + _onCompositionendInput: function () { + this._isInputComposing = false; + }, + /** + * @rivate + * @param {CompositionEvent} ev + */ + _onCompositionstartInput: function () { + this._isInputComposing = true; + }, + /** * @private * @param {KeyEvent} e */ _onKeydown: function (e) { + if (this._isInputComposing) { + return; + } switch(e.which) { case $.ui.keyCode.LEFT: this._focusPreceding(); diff --git a/addons/web/static/src/js/views/control_panel/search/search_facet.js b/addons/web/static/src/js/views/control_panel/search/search_facet.js index 917d42b7e41..22d957c030b 100644 --- a/addons/web/static/src/js/views/control_panel/search/search_facet.js +++ b/addons/web/static/src/js/views/control_panel/search/search_facet.js @@ -10,6 +10,8 @@ var SearchFacet = Widget.extend({ template: 'SearchView.SearchFacet', events: _.extend({}, Widget.prototype.events, { 'click .o_facet_remove': '_onFacetRemove', + 'compositionend': '_onCompositionend', + 'compositionstart': '_onCompositionstart', 'keydown': '_onKeydown', }), /** @@ -26,6 +28,7 @@ var SearchFacet = Widget.extend({ }); this.separator = this._getSeparator(); this.icon = this._getIcon(); + this._isComposing = false; }, //-------------------------------------------------------------------------- @@ -130,6 +133,20 @@ var SearchFacet = Widget.extend({ /** * @private + * @param {CompositionEvent} ev + */ + _onCompositionend: function (ev) { + this._isComposing = false; + }, + /** + * @private + * @param {CompositionEvent} ev + */ + _onCompositionstart: function (ev) { + this._isComposing = true; + }, + /** + * @private */ _onFacetRemove: function () { this.trigger_up('facet_removed', {group: this.facet}); @@ -139,6 +156,9 @@ var SearchFacet = Widget.extend({ * @param {KeyboardEvent} ev */ _onKeydown: function (ev) { + if (this._isComposing) { + return; + } switch (ev.which) { case $.ui.keyCode.BACKSPACE: this.trigger_up('facet_removed', {group: this.facet}); diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js index 2953b99f731..1c580a25bd6 100644 --- a/addons/web/static/src/js/views/graph/graph_controller.js +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -177,7 +177,7 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ .find('.o_graph_button[data-mode="stack"]') .data('stacked', state.stacked) .toggleClass('active', state.stacked) - .toggleClass('o_hidden', state.mode !== 'bar' || !this.renderer.isStackable); + .toggleClass('o_hidden', state.mode !== 'bar'); _.each(this.$measureList.find('.dropdown-item'), function (item) { var $item = $(item); $item.toggleClass('selected', $item.data('field') === state.measure); diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js index e24de02879c..6cb2c023d0e 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -71,8 +71,6 @@ return AbstractRenderer.extend({ this.chart = null; this.chartId = _.uniqueId('chart'); - // the bar charts are considered stackable a priori - this.isStackable = true; this.$legendTooltip = null; this.$tooltip = null; }, @@ -782,34 +780,9 @@ return AbstractRenderer.extend({ // prepare data var data = this._prepareData(dataPoints); - // The datapoints (with non zero value) can be grouped by - // their originIndex and associated labels. - // If at least one group formed in that way has two elements - // this.isStackable will be true. - var groupMemberCounts = data.labels.map(function () { - return {}; - }); - this.isStackable = false; - for (var i = 0; i < data.datasets.length; i++) { - var dataset = data.datasets[i]; - var originIndex = dataset.originIndex; - if (this.isStackable) { - break; - } - for (var j = 0; j < dataset.data.length; j++) { - if (dataset.data[j]) { - if (originIndex in groupMemberCounts[j]) { - this.isStackable = true; - break; - } else { - groupMemberCounts[j][originIndex] = 1; - } - } - } - } data.datasets.forEach(function (dataset, index) { // used when stacked - dataset.stack = self.isStackable && self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; // set dataset color var color = self._getColor(index); dataset.backgroundColor = color; diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 5ec42156000..4e0060fa5ff 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -8,20 +8,78 @@ odoo.define('web.ListController', function (require) { */ var core = require('web.core'); +var session = require('web.session'); +var framework = require('web.framework'); var BasicController = require('web.BasicController'); var DataExport = require('web.DataExport'); var Dialog = require('web.Dialog'); +var fieldUtils = require('web.field_utils'); var Sidebar = require('web.Sidebar'); var _t = core._t; var qweb = core.qweb; +/** + * Return data required for exporting the current list to a xls file. + * Recursive if some groups are nested. + * Note: this function is also used when the records are not grouped. + * In this scenario, an artificial single group with every record in + * the list is built. + * @param {Array} groups + * @param {Array} displayedFields + * @param {Array} allFields + * @returns {Array} list of groups with the following structure: + * 'data': list of records or list of sub-groups + * 'isGrouped': true if data contains sub-groups + * 'value': title of the group + * 'count': number of records in the group + * 'aggregateValues': dict container aggregated values of fields {field: aggregatedValue, ...} + * 'hideHeader': self explanatory, used when the records are not grouped at all. + */ +var processGroups = (groups, displayedFields, allFields) => { + let formatRecord = record => { + let data = _.pick(record.data, displayedFields) + let formattedData = {} + + Object.entries(data).forEach(([field_name, value]) => { + let field = allFields[field_name] + let formattedValue = fieldUtils.format[field.type](value, field, {data: record.data, forceString: true}) + formattedData[field_name] = formattedValue.replace(' ', '') + }); + return formattedData + } + + if (groups.length && groups[0].groupedBy && groups[0].groupedBy.length) { + // Recursively process sub-group + return groups.map(group => ({ + hideHeader: !!group.hideHeader, + isGrouped: true, + value: group.value !== undefined && group.value.toString() || _t("Undefined"), + count: group.count, + aggregateValues: _.pick(group.aggregateValues, displayedFields), + data: processGroups(group.data, displayedFields, allFields), + })) + } + // process records + return groups.map(group => ({ + hideHeader: !!group.hideHeader, + isGrouped: false, + value: group.value !== undefined && group.value.toString() || _t("Undefined"), + count: group.count, + aggregateValues: _.pick(group.aggregateValues, displayedFields), + data: group.data.map(formatRecord) + })) +} + var ListController = BasicController.extend({ /** * This key contains the name of the buttons template to render on top of * the list view. It can be overridden to add buttons in specific child views. */ buttons_template: 'ListView.buttons', + events: _.extend({}, BasicController.prototype.events, { + 'click .o_list_download': '_onExportRecords', + }), custom_events: _.extend({}, BasicController.prototype.custom_events, { activate_next_widget: '_onActivateNextWidget', add_record: '_onAddRecord', @@ -50,6 +108,10 @@ var ListController = BasicController.extend({ this.noLeaf = params.noLeaf; this.selectedRecords = params.selectedRecords || []; this.multipleRecordsSavingPromise = null; + this.fieldChangedPrevented = false; + this.activeActions = _.extend({}, this.activeActions, { + download: this.renderer.arch.attrs.download ? !!JSON.parse(this.renderer.arch.attrs.download) : true, + }); }, //-------------------------------------------------------------------------- @@ -134,6 +196,7 @@ var ListController = BasicController.extend({ }, trigger: 'manual', }); + this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this)); this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this)); this.$buttons.appendTo($node); } @@ -339,6 +402,31 @@ var ListController = BasicController.extend({ }); }, /** + * Export the current list data in a xls file. + * + * @private + */ + _downloadList() { + let groups = this.renderer.state.data + groups = this.renderer.isGrouped ? groups : [{data: groups, count: groups.length, hideHeader: true}] // Artificial single group + + let allFields = this.renderer.state.fields + let columns = this.renderer.columns.map(column => ({ + field: column.attrs.name, + aggregateValue: column.aggregate && column.aggregate.value, + string: allFields[column.attrs.name].string, + })) + + groups = processGroups(groups, columns.map(c => c.field), allFields) + + return session.get_file({ + url: '/web/list/export_xls', + data: {data: JSON.stringify({columns, groups, title: this._title})}, + complete: framework.unblockUI, + error: (error) => this.call('crash_manager', 'rpc_error', error), + }); + }, + /** * @override * @private */ @@ -371,6 +459,7 @@ var ListController = BasicController.extend({ */ _saveMultipleRecords: function (recordId, node, changes) { var self = this; + var fieldName = Object.keys(changes)[0]; var value = Object.values(changes)[0]; var recordIds = _.union([recordId], this.selectedRecords); var validRecordIds = recordIds.reduce(function (result, nextRecordId) { @@ -388,6 +477,7 @@ var ListController = BasicController.extend({ this.model.discardChanges(recordId); return this._confirmSave(recordId).then(reject); }; + let dialog; if (validRecordIds.length > 0) { let message; if (nbInvalid === 0) { @@ -399,13 +489,13 @@ var ListController = BasicController.extend({ _t("Do you want to set the value on the %d valid selected records? (%d invalid)"), validRecordIds.length, nbInvalid); } - Dialog.confirm(this, message, { + dialog = Dialog.confirm(this, message, { confirm_callback: () => { - this.model.saveRecords(recordId, validRecordIds) - .then(() => { + return this.model.saveRecords(recordId, validRecordIds, fieldName) + .then(async () => { this._updateButtons('readonly'); const state = this.model.get(this.handle); - this.renderer.updateState(state, {keepWidths: true}); + await this.renderer.updateState(state, {keepWidths: true}); resolve(Object.keys(changes)); }) .guardedCatch(rejectAndDiscard); @@ -413,10 +503,17 @@ var ListController = BasicController.extend({ cancel_callback: rejectAndDiscard, }); } else { - Dialog.alert(this, _t("No valid record to save"), { + dialog = Dialog.alert(this, _t("No valid record to save"), { confirm_callback: rejectAndDiscard, }); } + dialog.on('closed', this, async () => { + // we need to wait for the dialog to be actually closed, but + // the 'closed' event is triggered just before, and it prevents + // from focussing the cell + await Promise.resolve(); + this.renderer.focusCell(recordId, node); + }); }); }, /** @@ -587,6 +684,28 @@ var ListController = BasicController.extend({ this._discardChanges(); }, /** + * Used to detect if the discard button is about to be clicked. + * Some focusout events might occur and trigger a save which + * is not always wanted when clicking "Discard". + * + * @param {MouseEvent} ev + * @private + */ + _onDiscardMousedown: function (ev) { + var self = this; + this.fieldChangedPrevented = true; + window.addEventListener('mouseup', function (mouseupEvent) { + var preventedEvent = self.fieldChangedPrevented; + self.fieldChangedPrevented = false; + // If the user starts clicking (mousedown) on the button and stops clicking + // (mouseup) outside of the button, we want to trigger the original onFieldChanged + // Event that was prevented in the meantime. + if (ev.target !== mouseupEvent.target && preventedEvent.constructor.name === 'OdooEvent') { + self._onFieldChanged(preventedEvent); + } + }, { capture: true, once: true }); + }, + /** * Called when the user asks to edit a row -> Updates the controller buttons * * @param {OdooEvent} ev @@ -614,6 +733,14 @@ var ListController = BasicController.extend({ new DataExport(this, record, defaultExportFields).open(); }, /** + * Export Records in a xls file + * + * @private + */ + _onExportRecords() { + this._downloadList() + }, + /** * Opens the related form view. * * @private @@ -643,15 +770,16 @@ var ListController = BasicController.extend({ ev.stopPropagation(); const recordId = ev.data.dataPointID; - if (this.renderer.inMultipleRecordEdition(recordId)) { + if (this.fieldChangedPrevented) { + this.fieldChangedPrevented = ev; + } else if (this.renderer.inMultipleRecordEdition(recordId)) { // deal with edition of multiple lines - const _onSuccess = ev.data.onSuccess; ev.data.onSuccess = () => { - Promise.resolve(_onSuccess()).then(() => { - const savedRecordsPromise = this._saveMultipleRecords(ev.data.dataPointID, ev.target.__node, ev.data.changes); - this.multipleRecordsSavingPromise = savedRecordsPromise; - }); + this.multipleRecordsSavingPromise = + this._saveMultipleRecords(ev.data.dataPointID, ev.target.__node, ev.data.changes); }; + // disable onchanges as we'll save directly + ev.data.notifyChange = false; } this._super.apply(this, arguments); }, @@ -678,6 +806,29 @@ var ListController = BasicController.extend({ this._toggleSidebar(); }, /** + * If the record is set as dirty while in multiple record edition, + * we want to immediatly discard the change. + * + * @private + * @override + * @param {OdooEvent} ev + */ + _onSetDirty: function (ev) { + var self = this; + var recordId = ev.data.dataPointID; + if (this.renderer.inMultipleRecordEdition(recordId)) { + ev.stopPropagation(); + Dialog.alert(this, _t("No valid record to save"), { + confirm_callback: function () { + self.model.discardChanges(recordId); + self._confirmSave(recordId); + }, + }); + } else { + this._super.apply(this, arguments); + } + }, + /** * When the user clicks on one of the sortable column headers, we need to * tell the model to sort itself properly, to update the pager and to * rerender the view. 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 d9cc1115917..6f21653b70c 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 @@ -19,6 +19,7 @@ var utils = require('web.utils'); var _t = core._t; ListRenderer.include({ + RESIZE_DELAY: 200, custom_events: _.extend({}, ListRenderer.prototype.custom_events, { navigation_move: '_onNavigationMove', }), @@ -109,6 +110,7 @@ ListRenderer.include({ start: function () { if (this.editable) { core.bus.on('click', this, this._onWindowClicked.bind(this)); + core.bus.on('resize', this, _.debounce(this._onResize.bind(this), this.RESIZE_DELAY)); } return this._super(); }, @@ -320,6 +322,17 @@ ListRenderer.include({ this._selectCell(rowIndex, 0); }, /** + * Gives focus to a specific cell, given its row and its related column. + * + * @param {string} recordId + * @param {Object} column + */ + focusCell: function (recordId, column) { + var $row = this._getRow(recordId); + var cellIndex = this.columns.indexOf(column); + $row.find('.o_data_cell')[cellIndex].focus(); + }, + /** * Returns the recordID associated to the line which is currently in edition * or null if there is no line in edition. * @@ -339,9 +352,7 @@ ListRenderer.include({ * @returns {boolean} */ inMultipleRecordEdition: function (recordId) { - const record = this._getRecord(recordId) || {}; - const recordIds = [...new Set([recordId, ...this.selection])]; - return this.editable && recordIds.length > 1 && record.res_id; + return this.editable && this.selection.length > 1 && this.selection.includes(recordId); }, /** * Removes the line associated to the given recordID (the index of the row @@ -358,6 +369,7 @@ ListRenderer.include({ } if ($row.prop('rowIndex') - 1 === this.currentRow) { this.currentRow = null; + this._enableRecordSelectors(); } // destroy widgets first @@ -452,7 +464,11 @@ ListRenderer.include({ // Toggle selected class here so that style is applied at the end $row.toggleClass('o_selected_row', editMode); - $row.find('.o_list_record_selector input').prop('disabled', !record.res_id); + if (editMode) { + this._disableRecordSelectors(); + } else { + this._enableRecordSelectors(); + } return Promise.all(defs).then(function () { // necessary to trigger resize on fieldtexts @@ -494,6 +510,7 @@ ListRenderer.include({ onFailure: reject, }); }).then(changedFields => { + this._enableRecordSelectors(); // If any field has changed and if the list is in multiple edition, // we send a truthy boolean to _selectRow to tell it not to select // the following record. @@ -543,7 +560,14 @@ ListRenderer.include({ el.addEventListener(type, callback, options); this.eventListeners.push({ type, el, callback, options }); }, - + /** + * When editing a row, we want to disable all record selectors. + * + * @private + */ + _disableRecordSelectors: function () { + this.$('.o_list_record_selector input').attr('disabled', 'disabled'); + }, /** * Destroy all field widgets corresponding to a record. Useful when we are * removing a useless row. @@ -558,6 +582,12 @@ ListRenderer.include({ } }, /** + * @private + */ + _enableRecordSelectors: function () { + this.$('.o_list_record_selector input').attr('disabled', false); + }, + /** * This function freezes the column widths and forces a fixed table-layout, * once the browser has computed the optimal width of each column according * to the displayed records. We want to freeze widths s.t. it doesn't @@ -581,9 +611,18 @@ ListRenderer.include({ const table = this.el.getElementsByTagName('table')[0]; const thead = table.getElementsByTagName('thead')[0]; + // Set table layout auto and remove inline style to make sure that css + // rules apply (e.g. fixed width of record selector) + table.style.tableLayout = 'auto'; + [...table.getElementsByTagName('th')].forEach((th) => { + th.style.width = null; + }); + // Freeze each th width according to their size in auto layout - [...thead.getElementsByTagName('th')].forEach((th, index) => { - th.style.width = `${this.columnWidths ? this.columnWidths[index] : th.offsetWidth}px`; + const thElements = [...thead.getElementsByTagName('th')]; + const thWidths = thElements.map((th) => th.offsetWidth); + thElements.forEach((th, index) => { + th.style.width = `${this.columnWidths ? this.columnWidths[index] : thWidths[index]}px`; }); // Set the table layout to fixed @@ -639,6 +678,7 @@ ListRenderer.include({ return fixedWidths[type] || '1'; }, /** + * @private * * @returns {integer} */ @@ -1195,7 +1235,10 @@ ListRenderer.include({ return new Promise(function (resolve) { self.trigger_up('edit_line', { recordId: recordId, - onSuccess: resolve, + onSuccess: function () { + self._disableRecordSelectors(); + resolve(); + }, }); }); }); @@ -1386,6 +1429,7 @@ ListRenderer.include({ this.trigger_up('discard_changes', { recordID: ev.target.dataPointID, onSuccess: function () { + self._enableRecordSelectors(); var recordId = self._getRecordID(rowIndex); if (recordId) { var correspondingRow = self._getRow(recordId); @@ -1420,6 +1464,15 @@ ListRenderer.include({ } }, /** + * React to window resize events by recomputing the width of each column. + * + * @private + */ + _onResize: function () { + this.columnWidths = false; + this._freezeColumnWidths(); + }, + /** * If the list view editable, just let the event bubble. We don't want to * open the record in this case anyway. * diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js index 900f3764b0e..65526346c8e 100644 --- a/addons/web/static/src/js/views/list/list_model.js +++ b/addons/web/static/src/js/views/list/list_model.js @@ -40,12 +40,18 @@ odoo.define('web.ListModel', function (require) { * @param {string} referenceRecordId the record datapoint used to * generate the changes to apply to recordIds * @param {string[]} recordIds a list of record datapoint ids + * @param {string} fieldName the field to write */ - saveRecords: function (referenceRecordId, recordIds) { + saveRecords: function (referenceRecordId, recordIds, fieldName) { var self = this; var referenceRecord = this.localData[referenceRecordId]; var list = this.localData[referenceRecord.parentID]; - var changes = this._generateChanges(referenceRecord, {}); + // generate all record values to ensure that we'll write something + // (e.g. 2 records selected, edit a many2one in the first one, but + // reset same value, we still want to save this value on the other + // record) + var allChanges = this._generateChanges(referenceRecord, {changesOnly: false}); + var changes = _.pick(allChanges, fieldName); var records = recordIds.map(function (recordId) { return self.localData[recordId]; }); diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 94609544b10..5daa33125a7 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -996,11 +996,18 @@ var ListRenderer = BasicRenderer.extend({ * @private */ _updateSelection: function () { - var $selectedRows = this.$('tbody .o_list_record_selector input:checked') - .closest('tr'); - this.selection = _.map($selectedRows, function (row) { - return $(row).data('id'); + this.selection = []; + var self = this; + var $inputs = this.$('tbody .o_list_record_selector input:visible:not(:disabled)'); + var allChecked = $inputs.length > 0; + $inputs.each(function (index, input) { + if (input.checked) { + self.selection.push($(input).closest('tr').data('id')); + } else { + allChecked = false; + } }); + this.$('thead .o_list_record_selector input').prop('checked', allChecked); this.trigger_up('selection_changed', { selection: this.selection }); this._updateFooter(); }, @@ -1186,9 +1193,6 @@ var ListRenderer = BasicRenderer.extend({ _onSelectRecord: function (ev) { ev.stopPropagation(); this._updateSelection(); - if (!$(ev.currentTarget).find('input').prop('checked')) { - this.$('thead .o_list_record_selector input').prop('checked', false); - } }, /** * @private @@ -1209,7 +1213,8 @@ var ListRenderer = BasicRenderer.extend({ if (group.count) { this.trigger_up('toggle_group', { group: group, - onSuccess: function () { + onSuccess: () => { + this._updateSelection(); // Refocus the header after re-render unless the user // already focused something else by now if (document.activeElement.tagName === 'BODY') { diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index 47eb5ad49fe..733a812d328 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -13,7 +13,6 @@ odoo.define('web.PivotController', function (require) { */ var AbstractController = require('web.AbstractController'); -var config = require('web.config'); var core = require('web.core'); var framework = require('web.framework'); var session = require('web.session'); @@ -115,16 +114,6 @@ var PivotController = AbstractController.extend({ this._updateButtons(); } }, - /** - * @override - * @param {Object} params - */ - update: function (params) { - if (config.device.isMobile && params.context) { - params.context.pivot_column_groupby = []; - } - return this._super.apply(this, arguments); - }, //-------------------------------------------------------------------------- // Private @@ -218,12 +207,7 @@ var PivotController = AbstractController.extend({ .toggleClass('selected', isSelected); }); var noDataDisplayed = !state.hasData || !state.measures.length; - var $flipButton = this.$buttons.find('.o_pivot_flip_button'); - if (config.device.isMobile) { - $flipButton.toggleClass('o_hidden', true); - } else { - $flipButton.prop('disabled', noDataDisplayed); - } + this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed); this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed); this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed); }, diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js index b74b408c877..3affc975225 100644 --- a/addons/web/static/src/js/views/pivot/pivot_renderer.js +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -125,10 +125,7 @@ var PivotRenderer = AbstractRenderer.extend({ if (rowIndex > 0) { cellParams.title = groupbyLabels[rowIndex - 1]; } - className = 'o_pivot_header_cell'; - if (!config.device.isMobile) { - className += cell.isLeaf ? '_closed' : '_opened'; - } + className = 'o_pivot_header_cell' + (cell.isLeaf ? '_closed' : '_opened'); } cellParams.class = className; diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index 78e7c68cbe6..1b878cf3530 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -108,7 +108,6 @@ var PivotView = AbstractView.extend({ this.loadParams.default_order = params.default_order || this.arch.attrs.default_order; if (config.device.isMobile) { this.loadParams.colGroupBys = []; - this.loadParams.context.pivot_column_groupby = []; } diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js index 32bfda12594..4f8c42325db 100644 --- a/addons/web/static/src/js/views/search_panel.js +++ b/addons/web/static/src/js/views/search_panel.js @@ -77,6 +77,9 @@ var SearchPanel = Widget.extend({ * @param {Object} params * @param {Object} [params.defaultValues={}] the value(s) to activate by * default, for each filter and category + * @param {boolean} [params.defaultNoFilter=false] if true, select 'All' as + * value for each category that has no value specified in defaultValues + * (instead of looking in the localStorage for the last selected value) * @param {Object} params.fields * @param {string} params.model * @param {Array[]} params.searchDomain domain coming from controlPanel @@ -97,6 +100,12 @@ var SearchPanel = Widget.extend({ this.initialState = params.state; this.scrollTop = this.initialState && this.initialState.scrollTop || null; this.defaultValues = params.defaultValues || {}; + if (params.defaultNoFilter) { + Object.keys(this.categories).forEach((categoryId) => { + var fieldName = this.categories[categoryId].fieldName; + this.defaultValues[fieldName] = this.defaultValues[fieldName] || false; + }); + } this.fields = params.fields; this.model = params.model; this.className = params.classes.concat(['o_search_panel']).join(' '); @@ -181,7 +190,7 @@ var SearchPanel = Widget.extend({ return { sections: searchPanelSections, classes: classes, - } + }; }, /** * Export the current state (categories and filters) of the searchpanel. @@ -279,8 +288,9 @@ var SearchPanel = Widget.extend({ }); _.map(values, function (value) { var value = category.values[value.id]; - if (value.parentId) { - category.values[value.parentId].childrenIds.push(value.id); + var parentCategoryId = value.parentId; + if (parentCategoryId && parentCategoryId in category.values) { + category.values[parentCategoryId].childrenIds.push(value.id); } }); category.rootIds = _.filter(_.map(values, function (value) { @@ -293,7 +303,6 @@ var SearchPanel = Widget.extend({ // set active value var validValues = _.pluck(category.values, 'id').concat([false]); var value = this._getCategoryDefaultValue(category, validValues); - // if not set in localStorage either, select 'All' category.activeValueId = _.contains(validValues, value) ? value : false; // unfold ancestor values of active value to make it is visible diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index 130173a16fc..47e3a5309d9 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -320,6 +320,7 @@ var SelectCreateDialog = ViewDialog.extend({ this._super.apply(this, arguments); _.defaults(this.options, { initial_view: 'search' }); this.on_selected = this.options.on_selected || (function () {}); + this.on_closed = this.options.on_closed || (function () {}); this.initialIDs = this.options.initial_ids; this.viewType = 'list'; }, @@ -395,6 +396,10 @@ var SelectCreateDialog = ViewDialog.extend({ return fragment; }); }, + close: function () { + this._super.apply(this, arguments); + this.on_closed(); + }, create_edit_record: function () { var self = this; var dialog = new FormViewDialog(this, _.extend({}, this.options, { diff --git a/addons/web/static/src/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js index 1bb00be8d38..30c38690d17 100644 --- a/addons/web/static/src/js/widgets/auto_complete.js +++ b/addons/web/static/src/js/widgets/auto_complete.js @@ -29,16 +29,23 @@ return Widget.extend({ this.searching = true; this.search_string = ''; this.current_search = null; + this._isInputComposing = false; }, start: function () { var self = this; + this.$input.on('compositionend', function (ev) { + self._isInputComposing = false; + }); + this.$input.on('compositionstart', function (ev) { + self._isInputComposing = true; + }); this.$input.on('keyup', function (ev) { - if (ev.which === $.ui.keyCode.RIGHT) { + if (ev.which === $.ui.keyCode.RIGHT && !self._isInputComposing) { self.searching = true; ev.preventDefault(); return; } - if (ev.which === $.ui.keyCode.ENTER) { + if (ev.which === $.ui.keyCode.ENTER && !self._isInputComposing) { if (self.search_string.length) { self.select_item(ev); } @@ -68,6 +75,9 @@ return Widget.extend({ } }); this.$input.on('keydown', function (ev) { + if (self._isInputComposing) { + return; + } switch (ev.which) { case $.ui.keyCode.ENTER: diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index ac8e83c8e30..3f528fe7c37 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -12,7 +12,6 @@ var DateWidget = Widget.extend({ template: "web.datepicker", type_of_date: "date", events: { - 'change.datetimepicker': 'changeDatetime', 'error.datetimepicker': 'errorDatetime', 'change .o_datepicker_input': 'changeDatetime', 'click input': '_onInputClicked', @@ -257,6 +256,7 @@ var DateWidget = Widget.extend({ if (this._onScroll) { window.removeEventListener('scroll', this._onScroll, true); } + this.changeDatetime(); }, /** * Reacts to the datetimepicker being shown diff --git a/addons/web/static/src/scss/banner.scss b/addons/web/static/src/scss/banner.scss index d7237203897..be011d70698 100644 --- a/addons/web/static/src/scss/banner.scss +++ b/addons/web/static/src/scss/banner.scss @@ -1,8 +1,9 @@ .o_has_banner { .o_view_nocontent { top: 30%; - @media screen and (max-height: map-get($grid-breakpoints, 'md')) { + @include media-breakpoint-down(sm) { position: relative; + margin: auto; } } } diff --git a/addons/web/static/src/scss/control_panel.scss b/addons/web/static/src/scss/control_panel.scss index dd74c8ac010..c08695fc28d 100644 --- a/addons/web/static/src/scss/control_panel.scss +++ b/addons/web/static/src/scss/control_panel.scss @@ -1,13 +1,24 @@ .o_control_panel { - display: flex; - flex-flow: row wrap; border-bottom: 1px solid darken($o-control-panel-background-color, 20%); @include o-webclient-padding($top: 10px, $bottom: 10px); background-color: $o-control-panel-background-color; - > .breadcrumb { - width: 50%; + > div { + display: flex; + justify-content: space-between; + min-height: 30px; + } + + .breadcrumb, .o_cp_left { + flex: 1 1 auto; + } + + .o_cp_searchview, .o_cp_right { + flex: 0 0 50%; + } + + .breadcrumb { font-size: 18px; > li { @@ -19,8 +30,7 @@ } } - > .o_cp_searchview { - width: 50%; + .o_cp_searchview { min-height: $o-cp-breadcrumb-height; } @@ -30,10 +40,9 @@ background: white; } - > .o_cp_left { + .o_cp_left { display: flex; justify-content: space-between; - width: 50%; margin-top: 5px; > .o_cp_sidebar { @@ -65,9 +74,8 @@ } } - > .o_cp_right { + .o_cp_right { display: flex; - width: 50%; margin-top: 5px; > .o_cp_pager { diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss index b9f85f2d5c0..e3cba4e3770 100644 --- a/addons/web/static/src/scss/fields.scss +++ b/addons/web/static/src/scss/fields.scss @@ -196,6 +196,7 @@ img { height: 18px; width: 18px; + object-fit: cover; } &.o_tag_color_0 { background-color: #F1F1F1; diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss index 18c96fb6171..a46d4632c24 100644 --- a/addons/web/static/src/scss/kanban_view.scss +++ b/addons/web/static/src/scss/kanban_view.scss @@ -122,6 +122,7 @@ .oe_kanban_avatar { width: 20px; height: 20px; + object-fit: cover; margin-left: 6px; } } diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 0d29c3cec21..0e62f071d42 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -201,7 +201,7 @@ right: 0; top: 0; width: 10px; - z-index: 10; + z-index: 1; // must be over the sort caret (::after pseudo-element) } .o_list_record_remove_header { @@ -294,9 +294,11 @@ } .o_optional_columns_dropdown_toggle { + cursor: pointer; width: 10px; text-align: center; line-height: 30px; + z-index: 1; // must be over the resize handle } .o_optional_columns { diff --git a/addons/web/static/src/scss/modal.scss b/addons/web/static/src/scss/modal.scss index f33aec59431..e7243042d35 100644 --- a/addons/web/static/src/scss/modal.scss +++ b/addons/web/static/src/scss/modal.scss @@ -96,6 +96,11 @@ } } +body.modal-open { + // Allow vertical scrolling in modals on iOS + -webkit-overflow-scrolling: touch; +} + // Temporary fix for modals which are not instantiated thanks to the Dialog // JS classes (deprecated case) (see bootstrap_overridden.scss) + Frontend. // diff --git a/addons/web/static/src/scss/search_view_mobile.scss b/addons/web/static/src/scss/search_view_mobile.scss index dd72aaae83b..bf68732b2e5 100644 --- a/addons/web/static/src/scss/search_view_mobile.scss +++ b/addons/web/static/src/scss/search_view_mobile.scss @@ -83,10 +83,6 @@ } } .o_mobile_search_show_result { - position: fixed; - bottom: 0; - left: 0; - right: 0; padding: 15px; font-size: 17px; } diff --git a/addons/web/static/src/scss/ui.scss b/addons/web/static/src/scss/ui.scss index 9ab1729a4ac..5a46c9c01a8 100644 --- a/addons/web/static/src/scss/ui.scss +++ b/addons/web/static/src/scss/ui.scss @@ -91,3 +91,41 @@ span.o_force_ltr { /*rtl:ignore*/ direction: ltr; } + +// To fill the available space while keeping aspect ratio (crop). +// Assuming the important part of the image is its center. +.o_object_fit_cover { + object-fit: cover; +} + +.o_image_24_cover { + width: 24px; + height: 24px; + object-fit: cover; +} + +.o_image_40_cover { + width: 40px; + height: 40px; + object-fit: cover; +} + +.o_image_64_cover { + width: 64px; + height: 64px; + object-fit: cover; +} + +// Keep ratio but avoid cropping (so part of the background becomes visible). +.o_image_64_contain { + width: 64px; + height: 64px; + object-fit: contain; +} + +// When having a square is not necessary, the image will take less space if any +// of its width or height is smaller than 64px. +.o_image_64_max { + max-width: 64px; + max-height: 64px; +} diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 300aff10c00..f52818d5ed6 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -148,18 +148,22 @@ <t t-name="ControlPanel"> <div class="o_control_panel"> - <ol class="breadcrumb" role="navigation"/> - <div class="o_cp_searchview" role="search"> - <t t-call="SearchView"/> - </div> - <div class="o_cp_left"> - <div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/> - <aside class="o_cp_sidebar"/> + <div> + <ol class="breadcrumb" role="navigation"/> + <div class="o_cp_searchview" role="search"> + <t t-if="widget.withSearchBar" t-call="SearchView"/> + </div> </div> - <div class="o_cp_right"> - <div class="btn-group o_search_options" role="search"/> - <nav class="o_cp_pager" role="search" aria-label="Pager"/> - <nav class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"/> + <div> + <div class="o_cp_left"> + <div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/> + <aside class="o_cp_sidebar"/> + </div> + <div class="o_cp_right"> + <div class="btn-group o_search_options" role="search"/> + <nav class="o_cp_pager" role="search" aria-label="Pager"/> + <nav class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"/> + </div> </div> </div> </t> @@ -271,6 +275,9 @@ Create </button> </t> + <t t-if="widget.is_action_enabled('download')"> + <button type="button" class="btn btn-secondary fa fa-download o_list_download" title="Download xls" aria-label="Download xls"/> + </t> </div> </t> @@ -705,8 +712,8 @@ <t t-name="FieldMany2One"> <t t-if="widget.mode === 'readonly'"> - <a t-if="!widget.nodeOptions.no_open" t-att-tabindex="widget.attrs.tabindex" class="o_form_uri" href="#"/> - <span t-if="widget.nodeOptions.no_open"/> + <a t-if="!widget.noOpen" t-att-tabindex="widget.attrs.tabindex" class="o_form_uri" href="#"/> + <span t-if="widget.noOpen"/> </t> <div t-if="widget.mode === 'edit'" class="o_field_widget o_field_many2one" aria-atomic="true"> <div class="o_input_dropdown"> @@ -1161,7 +1168,14 @@ </div> <div t-name="SearchView" class="o_searchview" role="search" aria-autocomplete="list"> - <span class="o_searchview_more fa" title="Advanced Search..." role="img" aria-label="Advanced Search..."/> + <t t-if="widget.isMobile"> + <t t-call="MobileSearchView"/> + <button class="o_toggle_searchview_full o_hidden btn fa fa-filter"/> + <button class="o_enable_searchview btn btn-link fa fa-search"/> + </t> + <t t-else=""> + <span class="o_searchview_more fa" title="Advanced Search..." role="img" aria-label="Advanced Search..."/> + </t> </div> <div t-name="SearchView.SearchBar" class="o_searchview_input_container"> @@ -1230,40 +1244,17 @@ </select> </t> -<t t-name="MobileControlPanel"> - <div class="o_control_panel"> - <ol class="breadcrumb" role="navigation"/> - <div class="o_cp_searchview" role="search"> - <t t-call="MobileSearchView"/> - </div> - <div class="o_cp_left"> - <div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/> - <aside class="o_cp_sidebar"/> - </div> - <div class="o_cp_right"> - <div class="btn-group o_search_options" role="search"/> - <nav class="o_cp_pager" role="search" aria-label="Pager"/> - <nav class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"/> - </div> - </div> -</t> - -<t t-name="MobileSearchView"> - <div class="o_searchview" role="search" aria-autocomplete="list"> - <button class="o_enable_searchview btn fa fa-search"/> - <div class="o_mobile_search o_hidden"> - <div class="o_mobile_search_header"> - <span class="o_mobile_search_close float-left mt16 mb16 mr8 ml16"> - <i class="fa fa-arrow-left"/> - <strong class="float-right ml8">FILTER</strong> - </span> - <span class="float-right o_mobile_search_clear_facets mt16 mr16">CLEAR</span> - </div> - <div class="o_mobile_search_filter o_search_options mb8 mt8 ml16 mr16"/> - <div class="btn btn-primary o_mobile_search_show_result">SEE RESULT</div> - </div> +<div t-name="MobileSearchView" class="o_mobile_search o_hidden"> + <div class="o_mobile_search_header"> + <span class="o_mobile_search_close float-left mt16 mb16 mr8 ml16"> + <i class="fa fa-arrow-left"/> + <strong class="float-right ml8">FILTER</strong> + </span> + <span class="float-right o_mobile_search_clear_facets mt16 mr16">CLEAR</span> </div> -</t> + <div class="o_mobile_search_filter o_search_options mb8 mt8 ml16 mr16"/> + <div class="btn btn-primary o_mobile_search_show_result fixed-bottom">SEE RESULT</div> +</div> <div t-name="AddNewFavoriteMenu"> <button type="button" class="dropdown-item o_generator_menu o_add_favorite o_menu_header">Save Current Search</button> diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index bc40fc97011..002a52f572f 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -3518,7 +3518,7 @@ QUnit.module('ActionManager', { active_ids: [1], }, flags: { - withSearchPanel: false, + searchPanelDefaultNoFilter: true, }, }); var checkSessionStorage = false; diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index eca412c036a..aa86ff925e4 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -11,6 +11,7 @@ var KanbanView = require('web.KanbanView'); var ListView = require('web.ListView'); var session = require('web.session'); var testUtils = require('web.test_utils'); +var testUtilsDom = require('web.test_utils_dom'); var field_registry = require('web.field_registry'); var createView = testUtils.createView; @@ -3361,6 +3362,63 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('datetime fields do not trigger fieldChange before datetime completly picked', async function (assert) { + assert.expect(6); + + this.data.partner.onchanges = { + datetime: function () {}, + }; + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="datetime"/></form>', + res_id: 1, + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.step('onchange'); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + }); + + + testUtils.dom.openDatepicker(form.$('.o_datepicker')); + assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget'); + + // select a date and time + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch').first()); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch:eq(1)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .year:contains(2017)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .month').eq(3)); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .day:contains(22)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .fa-clock-o')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-hour')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .hour:contains(08)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-minute')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .minute:contains(25)')); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .timepicker-second')); + assert.verifySteps([], "should not have done any onchange yet"); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget .second:contains(35)')); + + assert.containsNone($('body'), '.bootstrap-datetimepicker-widget'); + assert.strictEqual(form.$('.o_datepicker_input').val(), "04/22/2017 08:25:35"); + assert.verifySteps(['onchange'], "should have done only one onchange"); + + form.destroy(); + }); QUnit.test('datetime field not visible in form view should not capture the focus on keyboard navigation', async function (assert) { assert.expect(1); @@ -3483,6 +3541,45 @@ QUnit.module('basic_fields', { list.destroy(); }); + QUnit.test('multi edition of datetime field in list view: edit date in input', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="datetime"/>' + + '</tree>', + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + }); + + // select two records and edit them + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell')); + assert.containsOnce(list, 'input.o_datepicker_input'); + list.$('.o_datepicker_input').val("10/02/2019 09:00:00"); + await testUtils.dom.triggerEvents(list.$('.o_datepicker_input'), ['change']); + + assert.containsOnce(document.body, '.modal'); + await testUtils.dom.click($('.modal .modal-footer .btn-primary')); + + assert.strictEqual(list.$('.o_data_row:first .o_data_cell').text(), "10/02/2019 09:00:00"); + assert.strictEqual(list.$('.o_data_row:nth(1) .o_data_cell').text(), "10/02/2019 09:00:00"); + + list.destroy(); + }); + QUnit.test('datetime field remove value', async function (assert) { assert.expect(4); @@ -5859,7 +5956,7 @@ QUnit.module('basic_fields', { QUnit.module('FieldProgressBar'); QUnit.test('Field ProgressBar: max_value should update', async function (assert) { - assert.expect(2); + assert.expect(3); this.data.partner.records = this.data.partner.records.slice(0,1); this.data.partner.records[0].qux = 2; @@ -5884,6 +5981,15 @@ QUnit.module('basic_fields', { viewOptions: { mode: 'edit', }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual( + args.args[1], + {int_field: 999, qux: 5, display_name: 'new name'}, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } }); assert.strictEqual(form.$('.o_progressbar_value').text(), '10 / 2', @@ -5895,6 +6001,435 @@ QUnit.module('basic_fields', { assert.strictEqual(form.$('.o_progressbar_value').text(), '999 / 5', 'The value of the progress bar should be correct after the update'); + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: value should not update in readonly mode when sliding the bar', async function (assert) { + assert.expect(4); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + assert.step(route); + return this._super.apply(this, arguments); + } + }); + var $view = $('#qunit-fixture').contents(); + $view.prependTo('body'); // => select with click position + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct') + + var $progressBarEl = form.$('.o_progress'); + var top = $progressBarEl.offset().top + 5; + var left = $progressBarEl.offset().left + 5; + try { + testUtils.triggerPositionalMouseEvent(left, top, "click"); + } catch (e) { + form.destroy(); + $view.remove(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'New value should be different than initial after click'); + + assert.verifySteps(["/web/dataset/call_kw/partner/read"]); + + form.destroy(); + $view.remove(); + }); + + QUnit.test('Field ProgressBar: value should not update in edit mode when sliding the bar', async function (assert) { + assert.expect(6); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + assert.step(route); + return this._super.apply(this, arguments); + } + }); + var $view = $('#qunit-fixture').contents(); + $view.prependTo('body'); // => select with click position + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct') + + var $progressBarEl = form.$('.o_progress'); + var top = $progressBarEl.offset().top + 5; + var left = $progressBarEl.offset().left + 5; + try { + testUtils.triggerPositionalMouseEvent(left, top, "click"); + } catch (e) { + form.destroy(); + $view.remove(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + assert.strictEqual(form.$('.o_progressbar_value.o_input').val(), "99", + 'Value of input is not changed'); + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'New value should be different than initial after click'); + + assert.verifySteps(["/web/dataset/call_kw/partner/read"]); + + form.destroy(); + $view.remove(); + }); + + QUnit.test('Field ProgressBar: value should update in edit mode when typing in input', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '69%', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: value should update in edit mode when typing in input with field max value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\'}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '69 / 0', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: max value should update in edit mode when typing in input with field max value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].qux, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), "0.44444", 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 69', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: Standard readonly mode is readonly', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + assert.step(route); + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + assert.containsNone(form, '.o_progressbar_value.o_input', 'no input in readonly mode'); + + assert.verifySteps(["/web/dataset/call_kw/partner/read"]); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: max value should update in readonly mode with right parameter when typing in input with field max value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true, \'editable_readonly\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].qux, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), "0.44444", 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 69', + 'New value should be different than initial after changing it'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: value should update in readonly mode with right parameter when typing in input with field value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'editable_readonly\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), "99", 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69.6', ['input', 'blur']); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '69%', + 'New value should be different than initial after changing it'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: write float instead of int works, in locale', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + translateParameters: { + thousands_sep: "#", + decimal_point: ":", + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 1037, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '1#037:9', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '1k%', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: write gibbrish instead of int throws warning', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + interceptsPropagate: { + call_service: function (ev) { + if (ev.data.service === 'notification') { + assert.strictEqual(ev.data.method, 'notify'); + assert.strictEqual(ev.data.args[0].title, 'Wrong value entered!'); + } + } + }, + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, 'trente sept virgule neuf', ['input']); + form.destroy(); }); diff --git a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js index db0fb3632d3..487cd8647b8 100644 --- a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js @@ -1106,6 +1106,47 @@ QUnit.module('fields', {}, function () { form.destroy(); }); + QUnit.test("many2many tags widget: save&new in edit mode doesn't close edit window", async function (assert) { + assert.expect(5); + for (var i = 1; i <= 10; i++) { + this.data.partner_type.records.push({ id: 100 + i, display_name: "Partner" + i}); + } + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name"/>' + + '<field name="timmy" widget="many2many_tags"/>' + + '</form>', + res_id: 1, + archs: { + 'partner_type,false,list': '<tree><field name="display_name"/></tree>', + 'partner_type,false,search': '<search><field name="display_name"/></search>', + 'partner_type,false,form': '<form><field name="display_name"/></form>' + }, + }); + await testUtils.form.clickEdit(form); + + await testUtils.fields.many2one.clickOpenDropdown('timmy'); + await testUtils.fields.many2one.clickItem('timmy','Create and Edit'); + assert.containsOnce($(document), '.modal .o_form_view', "should have opened the modal"); + + // Create multiple records with save & new + await testUtils.fields.editInput($('.modal input:first'), 'Ralts'); + await testUtils.dom.click($('.modal .btn-primary:nth-child(2)')); + assert.containsOnce($(document), '.modal .o_form_view', "modal should still be open"); + assert.equal($('.modal input:first')[0].value, '', "input should be empty") + + // Create another record and click save & close + await testUtils.fields.editInput($('.modal input:first'), 'Pikachu'); + await testUtils.dom.click($('.modal .btn-primary:first')); + assert.containsNone($(document),'.modal .o_list_view', "should have closed the modal"); + assert.containsN(form, '.o_field_many2manytags[name="timmy"] .badge', 2, "many2many tag should now contain 2 records"); + + form.destroy(); + }); + QUnit.test('many2many list add *many* records, remove, re-add', async function (assert) { assert.expect(5); diff --git a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js index f4cd0e1be0b..4d5b5d8aa89 100644 --- a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js @@ -566,6 +566,81 @@ QUnit.module('fields', {}, function () { form.destroy(); }); + QUnit.test("many2one doesn't trigger field_change when being emptied", async function (assert) { + assert.expect(2); + + const list = await createView({ + arch: ` + <tree editable="top"> + <field name="trululu"/> + </tree>`, + data: this.data, + model: 'partner', + View: ListView, + }); + + // Select two records + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()')); + + const $input = list.$('.o_field_widget[name=trululu] input'); + + await testUtils.fields.editInput($input, ""); + await testUtils.dom.triggerEvents($input, ['keyup']); + + assert.containsNone(document.body, '.modal', + "No save should be triggered when removing value"); + + await testUtils.fields.many2one.clickOpenDropdown('trululu'); + await testUtils.fields.many2one.clickHighlightedItem('trululu'); + + assert.containsOnce(document.body, '.modal', + "Saving should be triggered when selecting a value"); + await testUtils.dom.click($('.modal .btn-primary')); + + list.destroy(); + }); + + QUnit.test("focus tracking on a many2one in a list", async function (assert) { + assert.expect(4); + + const list = await createView({ + arch: '<tree editable="top"><field name="trululu"/></tree>', + archs: { + 'partner,false,form': '<form string="Partners"><field name="foo"/></form>', + }, + data: this.data, + model: 'partner', + View: ListView, + }); + + // Select two records + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()')); + + const input = list.$('.o_data_row:first() .o_data_cell:first() input')[0]; + + assert.strictEqual(document.activeElement, input, "Input should be focused when activated"); + + await testUtils.fields.many2one.clickOpenDropdown('trululu'); + await testUtils.fields.many2one.clickItem('trululu', 'Create'); + + // At this point, if the focus is correctly registered by the m2o, there + // should be only one modal (the "Create" one) and none for saving changes. + assert.containsOnce(document.body, '.modal', "There should be only one modal"); + + await testUtils.dom.click($('.modal .btn:not(.btn-primary)')); + + assert.strictEqual(document.activeElement, input, "Input should be focused after dialog closes"); + assert.strictEqual(input.value, "", "Input should be empty after discard"); + + list.destroy(); + }); + QUnit.test('many2one fields with option "no_open"', async function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 7f3f54e3ad7..24ce3e892c8 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1886,7 +1886,7 @@ QUnit.module('relational_fields', { }); assert.containsN(form, '.o_field_many2manytags.avatar.o_field_widget .badge', 2, "should have 2 records"); - assert.strictEqual(form.$('.o_field_many2manytags.avatar.o_field_widget .badge:first img').data('src'), '/web/image/partner/2/image_64', + assert.strictEqual(form.$('.o_field_many2manytags.avatar.o_field_widget .badge:first img').data('src'), '/web/image/partner/2/image_128', "should have correct avatar image"); form.destroy(); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index d77e8ceae79..f09456956ef 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -326,10 +326,12 @@ var MockServer = Class.extend({ if (field.type === "one2many" || field.type === "many2many") { field.views = {}; _.each(node.childNodes, function (children) { - relModel = field.relation; - relFields = $.extend(true, {}, self.data[relModel].fields); - field.views[children.tagName] = self._fieldsViewGet(children, relModel, - relFields, _.extend({}, context, {base_model_name: model})); + if (children.tagName) { // skip text nodes + relModel = field.relation; + relFields = $.extend(true, {}, self.data[relModel].fields); + field.views[children.tagName] = self._fieldsViewGet(children, relModel, + relFields, _.extend({}, context, {base_model_name: model})); + } }); } diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index 7cbab784829..d44d2e61a81 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -133,7 +133,7 @@ QUnit.module('Views', { data: [3,5], label: "Count", originIndex: 0, - stack: undefined, + stack: "", } ); assert.checkLegend(graph, 'Count'); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index c97b1916265..82607c43689 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -6,6 +6,7 @@ var BasicModel = require('web.BasicModel'); var core = require('web.core'); var basicFields = require('web.basic_fields'); var FormView = require('web.FormView'); +var ListRenderer = require('web.ListRenderer'); var ListView = require('web.ListView'); var mixins = require('web.mixins'); var NotificationService = require('web.NotificationService'); @@ -214,7 +215,7 @@ QUnit.module('Views', { }); QUnit.test('simple editable rendering', async function (assert) { - assert.expect(12); + assert.expect(15); var list = await createView({ View: ListView, @@ -225,6 +226,7 @@ QUnit.module('Views', { assert.containsN(list, 'th', 3, "should have 2 th"); assert.containsN(list, 'th', 3, "should have 3 th"); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); assert.containsOnce(list, 'td:contains(yop)', "should contain yop"); assert.isVisible(list.$buttons.find('.o_list_button_add'), @@ -242,6 +244,7 @@ QUnit.module('Views', { "should have a visible save button"); assert.isVisible(list.$buttons.find('.o_list_button_discard'), "should have a visible discard button"); + assert.containsNone(list, '.o_list_record_selector input:enabled'); await testUtils.dom.click(list.$buttons.find('.o_list_button_save')); @@ -251,6 +254,8 @@ QUnit.module('Views', { "should not have a visible save button"); assert.isNotVisible(list.$buttons.find('.o_list_button_discard'), "should not have a visible discard button"); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); + list.destroy(); }); @@ -1139,6 +1144,49 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('head selector is toggled by the other selectors', async function (assert) { + assert.expect(6); + + const list = await createView({ + arch: '<tree><field name="foo"/><field name="bar"/></tree>', + data: this.data, + groupBy: ['bar'], + model: 'foo', + View: ListView, + }); + + assert.ok(!list.$('thead .o_list_record_selector input')[0].checked, + "Head selector should be unchecked"); + + await testUtils.dom.click(list.$('.o_group_header:first()')); + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + + assert.containsN(list, 'tbody .o_list_record_selector input:checked', + 3, "All visible checkboxes should be checked"); + + await testUtils.dom.click(list.$('.o_group_header:last()')); + + assert.ok(!list.$('thead .o_list_record_selector input')[0].checked, + "Head selector should be unchecked"); + + await testUtils.dom.click(list.$('tbody .o_list_record_selector input:last()')); + + assert.ok(list.$('thead .o_list_record_selector input')[0].checked, + "Head selector should be checked"); + + await testUtils.dom.click(list.$('tbody .o_list_record_selector:first() input')); + + assert.ok(!list.$('thead .o_list_record_selector input')[0].checked, + "Head selector should be unchecked"); + + await testUtils.dom.click(list.$('.o_group_header:first()')); + + assert.ok(list.$('thead .o_list_record_selector input')[0].checked, + "Head selector should be checked"); + + list.destroy(); + }); + QUnit.test('selection is reset on reload', async function (assert) { assert.expect(5); @@ -1516,7 +1564,7 @@ QUnit.module('Views', { '</tree>', }); - assert.containsNone(list, 'o_resize', "There shouldn't be any resize handle if no data"); + assert.containsNone(list, '.o_resize', "There shouldn't be any resize handle if no data"); assertions.forEach(a => { assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected, `Field ${a.type} should have a fixed width of ${a.expected} pixels`); @@ -1527,6 +1575,66 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('width of some fields should be hardcoded if no data, and list initially invisible', async function (assert) { + const assertions = [ + { field: 'bar', expected: 40, type: 'Boolean' }, + { field: 'int_field', expected: 74, type: 'Integer' }, + { field: 'qux', expected: 92, type: 'Float' }, + { field: 'date', expected: 92, type: 'Date' }, + { field: 'datetime', expected: 146, type: 'Datetime' }, + { field: 'amount', expected: 104, type: 'Monetary' }, + { field: 'the_button', expected: 25, type: 'with custom width' }, + ]; + assert.expect(11); + + this.data.foo.fields.foo_o2m = {string: "Foo O2M", type: "one2many", relation: "foo"}; + const form = await createView({ + View: FormView, + model: 'foo', + data: this.data, + res_id: 1, + viewOptions: { mode: 'edit' }, + arch: `<form> + <sheet> + <notebook> + <page string="Page1"></page> + <page string="Page2"> + <field name="foo_o2m"> + <tree editable="bottom"> + <field name="bar"/> + <field name="foo"/> + <field name="int_field"/> + <field name="qux"/> + <field name="date"/> + <field name="datetime"/> + <field name="amount"/> + <button name="the_button" width="25px"/> + </tree> + </field> + </page> + </notebook> + </sheet> + </form>`, + }); + + assert.isNotVisible(form.$('.o_field_one2many')); + + await testUtils.dom.click(form.$('.nav-item:last-child .nav-link')); + + assert.isVisible(form.$('.o_field_one2many')); + + assert.containsNone(form, '.o_field_one2many .o_resize', + "There shouldn't be any resize handle if no data"); + assertions.forEach(a => { + assert.strictEqual(form.$(`.o_field_one2many th[data-name="${a.field}"]`)[0].offsetWidth, a.expected, + `Field ${a.type} should have a fixed width of ${a.expected} pixels`); + }); + assert.strictEqual(form.$('.o_field_one2many th[data-name="foo"]')[0].style.width, '100%', + "Char field should occupy the remaining space"); + + form.destroy(); + }); + QUnit.test('editable list: list view in an initially unselected notebook page', async function (assert) { assert.expect(9); @@ -1826,7 +1934,7 @@ QUnit.module('Views', { groupBy: ['int_field'], }); - assert.containsNone(list, 'o_resize', "There shouldn't be any resize handle if no data"); + assert.containsNone(list, '.o_resize', "There shouldn't be any resize handle if no data"); assertions.forEach(a => { assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected, `Field ${a.type} should have a fixed width of ${a.expected} pixels`); @@ -1911,6 +2019,43 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('column widths are re-computed on window resize', async function (assert) { + assert.expect(2); + + testUtils.mock.patch(ListRenderer, { + RESIZE_DELAY: 0, + }); + + this.data.foo.records[0].text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + + 'Sed blandit, justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus ' + + 'ipsum purus bibendum est.'; + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: `<tree editable="bottom"> + <field name="datetime"/> + <field name="text"/> + </tree>`, + }); + + const initialTextWidth = list.$('th[data-name="text"]')[0].offsetWidth; + const selectorWidth = list.$('th.o_list_record_selector')[0].offsetWidth; + + // simulate a window resize + list.$el.width(`${list.$el.width() / 2}px`); + core.bus.trigger('resize'); + await testUtils.nextTick(); + + const postResizeTextWidth = list.$('th[data-name="text"]')[0].offsetWidth; + const postResizeSelectorWidth = list.$('th.o_list_record_selector')[0].offsetWidth; + assert.ok(postResizeTextWidth < initialTextWidth); + assert.strictEqual(selectorWidth, postResizeSelectorWidth); + + testUtils.mock.unpatch(ListRenderer); + list.destroy(); + }); + QUnit.test('columns with an absolute width are never narrower than that width', async function (assert) { assert.expect(2); @@ -3399,6 +3544,193 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('export list view to xls with nested groups', async function (assert) { + assert.expect(5); + + let currencies = {}; + _.each(this.data.res_currency.records, function (currency) { + currencies[currency.id] = currency; + }); + + let list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: ` + <tree download="1"> + <field name="foo"/> + <field name="bar"/> + <field name="int_field" sum="Total"/> + <field name="datetime"/> + <field name="amount" widget="monetary"/> + <field name="currency_id" invisible="1"/> + <field name="o2m"/> + <field name="m2o"/> + <field name="m2m"/> + </tree>`, + groupBy: ['foo', 'bar'], + session: { + currencies: currencies, + get_file(args) { + let data = JSON.parse(args.data.data); + assert.strictEqual(args.url, '/web/list/export_xls', + "should call get_file with the correct url"); + assert.deepEqual(data.columns, [ + {field: "foo", string: "Foo"}, + {field: "bar", string: "Bar"}, + {field: "int_field", aggregateValue: 32, string: "int_field"}, + {field: "datetime", string: "Datetime Field"}, + {field: "amount", string: "Monetary field"}, + {field: "o2m", string: "O2M field"}, + {field: "m2o", string: "M2O field"}, + {field: "m2m", string: "M2M field"}, + ], "columns should be in the correct order with the string and aggregate value") + assert.deepEqual(data.groups[0], { + // Group "yop" + isGrouped: true, + count: 1, + aggregateValues: {int_field: 10}, + hideHeader: false, + value: "yop", + data: [{ + // Group "yop > true" + isGrouped: false, + count: 1, + aggregateValues: {int_field: 10}, + hideHeader: false, + value: "true", + data: [{ + // Record "yop > true" + foo: "yop", + bar: "True", + int_field: "10", + datetime: "12/12/2016 10:55:05", + amount: "1200.00€", + m2m: "2 records", + m2o: "Value 1", + o2m: "No records", + }] + }] + }); + assert.deepEqual(data.groups[1], { + // Group "blip" + isGrouped: true, + count: 2, + aggregateValues: {int_field: 5}, + hideHeader: false, + value: "blip", + data: [{ + // Group "blip > true" + isGrouped: false, + count: 1, + aggregateValues: {int_field: 9}, + hideHeader: false, + value: "true", + data: [], + }, { + // Group "blip > false" + isGrouped: false, + count: 1, + aggregateValues: {int_field: -4}, + hideHeader: false, + value: "false", + data: [], + }] + }); + assert.deepEqual(data.groups[2], { + // Group "gnap" + isGrouped: true, + count: 1, + aggregateValues: {int_field: 17}, + hideHeader: false, + value: "gnap", + data: [], + }); + args.complete(); + }, + }, + }); + + // open the first group + await testUtils.dom.click(list.$('.o_group_header:first')); + + // open subgroup + let $openGroup = list.$('tbody:nth(1)'); + await testUtils.dom.click($openGroup.find('.o_group_header:first')); + + // open the second group + await testUtils.dom.click(list.$('.o_group_header:nth(2)')); + + // Download + await testUtils.dom.click(list.$buttons.find('.o_list_download')); + + list.destroy(); + }); + + QUnit.test('export list view to xls', async function (assert) { + assert.expect(3); + + let currencies = {}; + _.each(this.data.res_currency.records, function (currency) { + currencies[currency.id] = currency; + }); + + let list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: ` + <tree> + <field name="foo"/> + <field name="bar"/> + <field name="int_field" sum="Total"/> + <field name="datetime"/> + <field name="amount" widget="monetary"/> + <field name="currency_id" invisible="1"/> + <field name="o2m"/> + <field name="m2o"/> + <field name="m2m"/> + </tree>`, + session: { + currencies: currencies, + get_file(args) { + let data = JSON.parse(args.data.data); + assert.strictEqual(args.url, '/web/list/export_xls', + "should call get_file with the correct url"); + assert.deepEqual(data.columns, [ + {field: "foo", string: "Foo"}, + {field: "bar", string: "Bar"}, + {field: "int_field", aggregateValue: 32, string: "int_field"}, + {field: "datetime", string: "Datetime Field"}, + {field: "amount", string: "Monetary field"}, + {field: "o2m", string: "O2M field"}, + {field: "m2o", string: "M2O field"}, + {field: "m2m", string: "M2M field"}, + ], "columns should be in the correct order with the string and aggregate value") + assert.deepEqual(data.groups[0], { + isGrouped: false, + count: 4, + aggregateValues: {}, + hideHeader: true, + value: "Undefined", + data: [ + {foo: "yop", bar: "True", int_field: "10", datetime: "12/12/2016 10:55:05", amount: "1200.00€", m2m: "2 records", m2o: "Value 1", o2m: "No records"}, + {foo: "blip", bar: "True", int_field: "9", datetime: "", amount: "$500.00", m2m: "3 records", m2o: "Value 2", o2m: "No records"}, + {foo: "gnap", bar: "True", int_field: "17", datetime: "", amount: "$300.00", m2m: "No records", m2o: "Value 1", o2m: "No records"}, + {foo: "blip", bar: "False", int_field: "-4", datetime: "", amount: "$0.00", m2m: "1 record", m2o: "Value 1", o2m: "No records"}, + ], + }); + args.complete(); + }, + }, + }); + + // Download + await testUtils.dom.click(list.$buttons.find('.o_list_download')); + + list.destroy(); + }); + QUnit.test('grouped list on selection field at level 2', async function (assert) { assert.expect(4); @@ -3475,7 +3807,7 @@ QUnit.module('Views', { }); QUnit.test('edition: create new line, then discard', async function (assert) { - assert.expect(8); + assert.expect(11); var list = await createView({ View: ListView, @@ -3490,11 +3822,13 @@ QUnit.module('Views', { "create button should be visible"); assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0, "discard button should be hidden"); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); await testUtils.dom.click(list.$buttons.find('.o_list_button_add')); assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 0, "create button should be hidden"); assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 1, "discard button should be visible"); + assert.containsNone(list, '.o_list_record_selector input:enabled'); await testUtils.dom.click(list.$buttons.find('.o_list_button_discard')); assert.containsN(list, 'tr.o_data_row', 4, "should still have 4 records"); @@ -3502,6 +3836,8 @@ QUnit.module('Views', { "create button should be visible again"); assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0, "discard button should be hidden again"); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); + list.destroy(); }); @@ -4122,37 +4458,6 @@ QUnit.module('Views', { form.destroy(); }); - QUnit.test('navigation with tab in editable list with only readonly fields', async function (assert) { - assert.expect(6); - - var list = await createView({ - View: ListView, - model: 'foo', - data: this.data, - arch: '<tree editable="bottom">' + - '<field name="m2o" attrs="{\'readonly\': [(\'int_field\', \'>\', 9)]}"/>' + - '<field name="int_field" readonly="1"/>' + - '</tree>', - }); - - assert.hasClass(list.$('.o_data_row:first .o_data_cell:first'), 'o_readonly_modifier'); - assert.doesNotHaveClass(list.$('.o_data_row:nth(1) .o_data_cell:first'), 'o_readonly_modifier'); - - // try to enter first row in edition - await testUtils.dom.click(list.$('.o_data_row .o_data_cell:first')); - - assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row'); - assert.strictEqual(document.activeElement, list.$('.o_selected_row .o_field_widget[name=m2o]').get(0)); - - // press tab to move to next focusable field (next line here) - $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); - await testUtils.nextTick(); - assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row'); - assert.strictEqual(document.activeElement, list.$('.o_selected_row .o_field_many2one input').get(0)); - - list.destroy(); - }); - QUnit.test('edition, then navigation with tab (with a readonly field)', async function (assert) { // This test makes sure that if we have 2 cells in a row, the first in // edit mode, and the second one readonly, then if we edit and press TAB, @@ -5117,8 +5422,34 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('editable list view: single edition with selected records', async function (assert) { + assert.expect(2); + + const list = await createView({ + arch: `<tree editable="top"><field name="foo"/></tree>`, + data: this.data, + model: 'foo', + View: ListView, + }); + + // Select first record + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + + // Edit the second + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:first()')); + await testUtils.fields.editInput(list.$('.o_data_row:eq(1) .o_data_cell:first() input'), "oui"); + await testUtils.dom.click($('.o_list_button_save')); + + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell:first()').text(), "yop", + "First row should remain unchanged"); + assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell:first()').text(), "oui", + "Second row should have been updated"); + + list.destroy(); + }); + QUnit.test('editable list view: multi edition', async function (assert) { - assert.expect(22); + assert.expect(24); var list = await createView({ View: ListView, @@ -5167,8 +5498,6 @@ QUnit.module('Views', { assert.verifySteps(['create', 'read']); // edit a field - // We deliberately unselect the first row to check the multi edit union (recordId + selectedRecordIds) - await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666); await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); @@ -5176,6 +5505,8 @@ QUnit.module('Views', { await testUtils.dom.click($('.modal .btn:contains(Cancel)')); assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "changes have been discarded and row is back to readonly"); + assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')[0], + "focus should be given to the most recently edited cell after discard"); await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666); await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)')); @@ -5188,12 +5519,70 @@ QUnit.module('Views', { assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip666", "the second row should be updated"); assert.containsNone(list, '.o_data_cell input.o_field_widget', "no field should be editable anymore"); + assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')[0], + "focus should be given to the most recently edited cell after confirm"); + + list.destroy(); + }); + + QUnit.test('editable list view: multi edition and onchanges', async function (assert) { + assert.expect(10); + + this.data.foo.onchanges = { + foo: function (obj) { + obj.int_field = obj.foo.length; + }, + }; + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</tree>', + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'write') { + args.args[1].int_field = args.args[1].foo.length; + } + return this._super.apply(this, arguments); + }, + }); + + // edit a single record to validate that onchanges work + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'hi'); + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); + await testUtils.dom.click(list.$('.o_list_button_discard')); + + assert.containsOnce(document.body, '.modal'); // discard dialog + await testUtils.dom.click($('.modal .btn-primary')); + + assert.verifySteps(['/web/dataset/search_read', 'onchange']); + + // select two records + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + // edit foo, first row + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'hello'); + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); + + assert.containsOnce(document.body, '.modal'); // save dialog + await testUtils.dom.click($('.modal .btn-primary')); + + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "hello5"); + assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "hello5"); + + assert.verifySteps(['write', 'read'], "should not perform the onchange in multi edition"); list.destroy(); }); QUnit.test('editable list view: multi edition error and cancellation handling', async function (assert) { - assert.expect(3); + assert.expect(12); var list = await createView({ View: ListView, @@ -5201,25 +5590,151 @@ QUnit.module('Views', { data: this.data, arch: '<tree editable="bottom">' + '<field name="foo" required="1"/>' + + '<field name="int_field"/>' + '</tree>', }); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); + // select two records await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); // edit a line and cancel await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + assert.containsNone(list, '.o_list_record_selector input:enabled'); await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "abc"); await testUtils.dom.click($('.modal .btn:contains("Cancel")')); - assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop', "first cell should have discarded any change"); + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "first cell should have discarded any change"); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); + + // edit a line with an invalid format type + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); + assert.containsNone(list, '.o_list_record_selector input:enabled'); + await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=int_field]'), "hahaha"); + assert.containsOnce(document.body, '.modal', "there should be an opened modal"); + await testUtils.dom.click($('.modal .btn-primary')); + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "changes should be discarded"); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); // edit a line with an invalid value await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + assert.containsNone(list, '.o_list_record_selector input:enabled'); await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), ""); assert.containsOnce(document.body, '.modal', "there should be an opened modal"); await testUtils.dom.click($('.modal .btn-primary')); - assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop', "changes should be discarded"); + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "changes should be discarded"); + assert.containsN(list, '.o_list_record_selector input:enabled', 5); + + list.destroy(); + }); + + QUnit.test('editable list view: multi edition of many2one: set same value', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="m2o"/>' + + '</tree>', + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args, [[1, 2, 3, 4], { m2o: 1 }], + "should force write value on all selected records"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(list.$('.o_list_many2one').text(), "Value 1Value 2Value 1Value 1"); + + // select all records (the first one has value 1 for m2o) + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); + + // set m2o to 1 in first record + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); + await testUtils.fields.many2one.searchAndClickItem('m2o', {search: 'Value 1'}); + + assert.containsOnce(document.body, '.modal'); + + await testUtils.dom.click($('.modal .modal-footer .btn-primary')); + + assert.strictEqual(list.$('.o_list_many2one').text(), "Value 1Value 1Value 1Value 1"); + + list.destroy(); + }); + + QUnit.test('editable list view: clicking on "Discard changes" in multi edition', async function (assert) { + assert.expect(2); + + const list = await createView({ + arch: ` + <tree editable="top"> + <field name="foo"/> + </tree>`, + data: this.data, + model: 'foo', + View: ListView, + }); + + // select two records + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()')); + list.$('.o_data_row:first() .o_data_cell:first() input').val("oof"); + + const $discardButton = list.$buttons.find('.o_list_button_discard'); + + // Simulates an actual click (event chain is: mousedown > change > blur > focus > mouseup > click) + await testUtils.dom.triggerEvents($discardButton, ['mousedown']); + await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'), + ['change', 'blur', 'focusout']); + await testUtils.dom.triggerEvents($discardButton, ['focus']); + $discardButton[0].dispatchEvent(new MouseEvent('mouseup')); + await testUtils.dom.click($discardButton); + + assert.ok($('.modal').text().includes("Warning"), "Modal should ask to discard changes"); + await testUtils.dom.click($('.modal .btn-primary')); + + assert.strictEqual(list.$('.o_data_row:first() .o_data_cell:first()').text(), "yop"); + + list.destroy(); + }); + + QUnit.test('editable list view (multi edition): mousedown on "Discard", but mouseup somewhere else', async function (assert) { + assert.expect(1); + + const list = await createView({ + arch: ` + <tree editable="top"> + <field name="foo"/> + </tree>`, + data: this.data, + model: 'foo', + View: ListView, + }); + + // select two records + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()')); + list.$('.o_data_row:first() .o_data_cell:first() input').val("oof"); + + // Simulates a pseudo drag and drop + await testUtils.dom.triggerEvents(list.$buttons.find('.o_list_button_discard'), ['mousedown']); + await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'), + ['change', 'blur', 'focusout']); + await testUtils.dom.triggerEvents($(document.body), ['focus']); + window.dispatchEvent(new MouseEvent('mouseup')); + await testUtils.nextTick(); + + assert.ok($('.modal').text().includes("Confirmation"), "Modal should ask to save changes"); + await testUtils.dom.click($('.modal .btn-primary')); list.destroy(); }); @@ -5265,6 +5780,65 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('editable list view: many2one with readonly modifier', async function (assert) { + assert.expect(2); + + const list = await createView({ + arch: + `<tree editable="top"> + <field name="m2o" readonly="1"/> + <field name="foo"/> + </tree>`, + data: this.data, + model: 'foo', + View: ListView, + }); + + // edit a field + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + + assert.containsOnce(list, '.o_data_row:eq(0) .o_data_cell:eq(0) a[name="m2o"]'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1) input')[0], + "focus should go to the char input"); + + list.destroy(); + }); + + QUnit.test('editable list view: multi edition: edit and validate last row', async function (assert) { + assert.expect(3); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="int_field"/>' + + '</tree>', + // in this test, we want to accurately mock what really happens, that is, input + // fields only trigger their changes on 'change' event, not on 'input' + fieldDebounce: 100000, + }); + + assert.containsN(list, '.o_data_row', 4); + + // select all records + await testUtils.dom.click(list.$('.o_list_view thead .o_list_record_selector input')); + + // edit last cell of last line + await testUtils.dom.click(list.$('.o_data_row:last .o_data_cell:last')); + testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), '666'); + await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_data_cell:last input'), 'enter'); + + assert.containsOnce(document.body, '.modal'); + await testUtils.dom.click($('.modal .btn-primary')); + + assert.containsN(list, '.o_data_row', 4, + "should not create a new row as we were in multi edition"); + + list.destroy(); + }); + QUnit.test('list grouped by date:month', async function (assert) { assert.expect(1); diff --git a/addons/web/static/tests/views/mobile_control_panel_tests.js b/addons/web/static/tests/views/mobile_control_panel_tests.js index 19226741474..be0d197ef44 100644 --- a/addons/web/static/tests/views/mobile_control_panel_tests.js +++ b/addons/web/static/tests/views/mobile_control_panel_tests.js @@ -39,8 +39,61 @@ QUnit.module('Mobile Search view Screen', { }; }, }, function () { - QUnit.test('can activate a filter with mobile search view', async function (assert) { - assert.expect(3); + QUnit.test('can activate a filter with mobile search view in quick search mode', async function (assert) { + assert.expect(7); + + var searchRPCFlag = false; + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + if (searchRPCFlag) { + assert.deepEqual(args.domain, [['foo', 'ilike', 'A']], + "domain should have been properly transferred to list view"); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction(1); + + assert.strictEqual($('button.o_enable_searchview.fa-search').length, 1, + "should display a button to open the searchview"); + assert.deepEqual($('.o_searchview_input_container:visible').length, 0, + "Quick search input should be hidden"); + + // open the search view + await testUtils.dom.click($('button.o_enable_searchview')); + assert.deepEqual($('.o_toggle_searchview_full:visible').length, 1, + "should display a button to expand the searchview"); + assert.deepEqual($('.o_searchview_input_container:visible').length, 1, + "Quick search input should now be visible"); + + searchRPCFlag = true; + + // use quick search input + $('.o_searchview_input') + .val("A") + .trigger($.Event('keypress', { which: 65, keyCode: 65 })); + await testUtils.nextTick(); + actionManager.$('.o_searchview_input') + .trigger($.Event('keydown', { which: $.ui.keyCode.ENTER, keyCode: $.ui.keyCode.ENTER })); + await testUtils.nextTick(); + + // close quick search + await testUtils.dom.click($('button.o_enable_searchview.fa-close')); + assert.deepEqual($('.o_toggle_searchview_full:visible').length, 0, + "Expand icon shoud be hidden"); + assert.deepEqual($('.o_searchview_input_container:visible').length, 0, + "Quick search input should be hidden"); + + actionManager.destroy(); + }); + + QUnit.test('can activate a filter with mobile search view in full screen mode', async function (assert) { + assert.expect(4); var filterActiveFlag = false; @@ -61,20 +114,25 @@ QUnit.module('Mobile Search view Screen', { assert.ok(!$('.o_mobile_search').is(':visible'), 'mobile search view is not visible'); + // open the search view await testUtils.dom.click($('button.o_enable_searchview')); - + // open it in full screen + await testUtils.dom.click($('.o_toggle_searchview_full')); assert.ok($('.o_mobile_search').is(':visible'), 'mobile search view is visible'); // open filter sub menu await testUtils.dom.click($('button.o_dropdown_toggler_btn').first()); - filterActiveFlag = true; - // click on Active filter await testUtils.dom.click($('.o_filters_menu a:contains(Active)')); + // closing search view + await testUtils.dom.click($('.o_mobile_search_close')); + assert.ok(!$('.o_mobile_search').is(':visible'), + 'mobile search view is not visible'); + actionManager.destroy(); }); diff --git a/addons/web/static/tests/views/search_panel_tests.js b/addons/web/static/tests/views/search_panel_tests.js index e580819e0ac..632158d9b1e 100644 --- a/addons/web/static/tests/views/search_panel_tests.js +++ b/addons/web/static/tests/views/search_panel_tests.js @@ -571,6 +571,64 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('category has been archived', async function (assert) { + assert.expect(2); + + this.data.company.fields.active = {type: 'boolean', string: 'Archived'}; + this.data.company.records = [ + { + name: 'Company 5', + id: 5, + active: true, + }, { + name: 'child of 5 archived', + parent_id: 5, + id: 666, + active: false, + }, { + name: 'child of 666', + parent_id: 666, + id: 777, + active: true, + } + ]; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': `<search><searchpanel><field name="company_id"/></searchpanel></search>`, + }, + mockRPC: async function (route, args) { + if (route === '/web/dataset/call_kw/partner/search_panel_select_range') { + var results = await this._super.apply(this, arguments); + results.values = results.values.filter(rec => rec.active !== false); + return Promise.resolve(results); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_search_panel_category_value', 2, + 'The number of categories should be 2: All and Company 5'); + + assert.containsNone(kanban, '.o_toggle_fold', + 'None of the categories should have children'); + + kanban.destroy(); + }); + QUnit.test('use two categories to refine search', async function (assert) { assert.expect(14); @@ -2064,27 +2122,57 @@ QUnit.module('Views', { actionManager.destroy(); }); - QUnit.test('disable search panel onExecuteAction', async function (assert) { - assert.expect(6); + QUnit.test('after onExecuteAction, selects "All" as default category value', async function (assert) { + assert.expect(4); + + var Storage = RamStorage.extend({ + getItem: function (key) { + assert.step('getItem ' + key); + return 3; // 'asustek' + }, + setItem: function (key, value) { + assert.step('setItem ' + key + ' to ' + value); + }, + }); + var RamStorageService = AbstractStorageService.extend({ + storage: new Storage(), + }); var actionManager = await createActionManager({ actions: this.actions, archs: this.archs, data: this.data, - services: this.services, + services: { + local_storage: RamStorageService, + }, }); await actionManager.doAction(2); - await testUtils.dom.click(actionManager.$('.o_form_view button:contains("multi view")')); + assert.containsOnce(actionManager, '.o_kanban_view'); - assert.containsNone(actionManager, '.o_search_panel'); + assert.containsOnce(actionManager, '.o_search_panel'); + assert.containsOnce(actionManager, '.o_search_panel_category_value:first .active'); - await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); - assert.containsOnce(actionManager, '.o_list_view'); - assert.containsNone(actionManager, '.o_search_panel'); + assert.verifySteps([]); // should not communicate with localStorage + + actionManager.destroy(); + }); + + QUnit.test('search panel is not instantiated if stated in context', async function (assert) { + assert.expect(2); + + this.actions[0].context = {search_panel: false}; + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: this.services, + }); + + await actionManager.doAction(2); + await testUtils.dom.click(actionManager.$('.o_form_view button:contains("multi view")')); - await testUtils.dom.click(actionManager.$('.o_cp_switch_kanban')); assert.containsOnce(actionManager, '.o_kanban_view'); assert.containsNone(actionManager, '.o_search_panel'); diff --git a/addons/web/static/tests/widgets/domain_selector_tests.js b/addons/web/static/tests/widgets/domain_selector_tests.js index 305315b72c6..8c246fd8399 100644 --- a/addons/web/static/tests/widgets/domain_selector_tests.js +++ b/addons/web/static/tests/widgets/domain_selector_tests.js @@ -174,6 +174,7 @@ QUnit.module('DomainSelector', { await testUtils.dom.clickFirst($('.bootstrap-datetimepicker-widget :not(.today)[data-action="selectDay"]')); assert.notEqual(domainSelector.$(".o_datepicker:visible input").val(), val, "datepicker value should have changed"); + await testUtils.dom.click($('.bootstrap-datetimepicker-widget a[data-action=close]')); domainSelector.destroy(); }); |
