summaryrefslogtreecommitdiff
path: root/addons/web/static
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-09-20 17:33:22 +0200
committerChristophe Simonis <chs@odoo.com>2019-09-20 17:34:45 +0200
commit64e43808b79badb5869a8d73cc1cc3f3b3aa8e12 (patch)
tree9a22d5f6c03073a4d10faedea8df23a5211e8b8f /addons/web/static
parent944891ced081cda3b8b3db5faf3b7ac23b6b4ce8 (diff)
parent58a83d12228fbbd4e9dde542e133d264204ee0f3 (diff)
[MERGE] forward port branch saas-12.5 up to 58a83d12228
Diffstat (limited to 'addons/web/static')
-rw-r--r--addons/web/static/src/js/chrome/action_manager_act_window.js2
-rw-r--r--addons/web/static/src/js/chrome/user_menu.js2
-rw-r--r--addons/web/static/src/js/core/dom.js7
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js106
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js102
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js3
-rw-r--r--addons/web/static/src/js/views/abstract_view.js18
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js6
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js2
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_renderer.js1
-rw-r--r--addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js31
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar.js20
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_facet.js20
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js2
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js29
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js173
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js69
-rw-r--r--addons/web/static/src/js/views/list/list_model.js10
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js21
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js18
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js5
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js1
-rw-r--r--addons/web/static/src/js/views/search_panel.js17
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js5
-rw-r--r--addons/web/static/src/js/widgets/auto_complete.js14
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js2
-rw-r--r--addons/web/static/src/scss/banner.scss3
-rw-r--r--addons/web/static/src/scss/control_panel.scss28
-rw-r--r--addons/web/static/src/scss/fields.scss1
-rw-r--r--addons/web/static/src/scss/kanban_view.scss1
-rw-r--r--addons/web/static/src/scss/list_view.scss4
-rw-r--r--addons/web/static/src/scss/modal.scss5
-rw-r--r--addons/web/static/src/scss/search_view_mobile.scss4
-rw-r--r--addons/web/static/src/scss/ui.scss38
-rw-r--r--addons/web/static/src/xml/base.xml85
-rw-r--r--addons/web/static/tests/chrome/action_manager_tests.js2
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js537
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_many2many_tests.js41
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_many2one_tests.js75
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js2
-rw-r--r--addons/web/static/tests/helpers/mock_server.js10
-rw-r--r--addons/web/static/tests/views/graph_tests.js2
-rw-r--r--addons/web/static/tests/views/list_tests.js656
-rw-r--r--addons/web/static/tests/views/mobile_control_panel_tests.js68
-rw-r--r--addons/web/static/tests/views/search_panel_tests.js106
-rw-r--r--addons/web/static/tests/widgets/domain_selector_tests.js1
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('&nbsp;', '')
+ });
+ 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();
});