summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src')
-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
35 files changed, 641 insertions, 214 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>