summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-10-09 02:08:17 +0200
committerChristophe Simonis <chs@odoo.com>2019-10-09 02:08:17 +0200
commitd74b4518052066446e2a1975a91ce53c745ca75a (patch)
treec08180f88a512d15e37fbec628931f02b3825f65 /addons/web/static/src
parentec162dd0cb5622ab6e56db1a63dab5f531a96f4a (diff)
parentf4105eb9c7010f8693e5d2fd20c0bd9d9cc2e483 (diff)
[MERGE] forward port branch 13.0 up to f4105eb9c70
Diffstat (limited to 'addons/web/static/src')
-rw-r--r--addons/web/static/src/css/reset.min.css4
-rw-r--r--addons/web/static/src/js/chrome/action_manager_act_window.js5
-rw-r--r--addons/web/static/src/js/core/dom.js1
-rw-r--r--addons/web/static/src/js/core/service_mixins.js1
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js56
-rw-r--r--addons/web/static/src/js/fields/field_utils.js5
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js19
-rw-r--r--addons/web/static/src/js/fields/special_fields.js2
-rw-r--r--addons/web/static/src/js/libs/bootstrap.js65
-rw-r--r--addons/web/static/src/js/tools/debug_manager.js1
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js16
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js18
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js27
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js30
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js9
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_model.js2
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_view.js4
-rw-r--r--addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js2
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_filters.js36
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js3
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js1
-rw-r--r--addons/web/static/src/js/views/list/list_confirm_dialog.js78
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js196
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js517
-rw-r--r--addons/web/static/src/js/views/list/list_model.js5
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js39
-rw-r--r--addons/web/static/src/js/views/list/list_view.js19
-rw-r--r--addons/web/static/src/js/widgets/data_export.js56
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js10
-rw-r--r--addons/web/static/src/js/widgets/translation_dialog.js13
-rw-r--r--addons/web/static/src/scss/bootstrap_overridden.scss2
-rw-r--r--addons/web/static/src/scss/bootstrap_overridden_report.scss4
-rw-r--r--addons/web/static/src/scss/datepicker.scss14
-rw-r--r--addons/web/static/src/scss/fonts.scss8
-rw-r--r--addons/web/static/src/scss/kanban_view.scss1
-rw-r--r--addons/web/static/src/scss/kanban_view_mobile.scss1
-rw-r--r--addons/web/static/src/scss/list_view.scss178
-rw-r--r--addons/web/static/src/scss/list_view_extra.scss8
-rw-r--r--addons/web/static/src/scss/modal.scss11
-rw-r--r--addons/web/static/src/scss/search_panel.scss2
-rw-r--r--addons/web/static/src/scss/ui.scss1
-rw-r--r--addons/web/static/src/scss/utils.scss23
-rw-r--r--addons/web/static/src/xml/base.xml42
-rw-r--r--addons/web/static/src/xml/translation_dialog.xml9
44 files changed, 987 insertions, 557 deletions
diff --git a/addons/web/static/src/css/reset.min.css b/addons/web/static/src/css/reset.min.css
index 97c0b78b002..b6163764a5c 100644
--- a/addons/web/static/src/css/reset.min.css
+++ b/addons/web/static/src/css/reset.min.css
@@ -55,10 +55,6 @@ ul ul {
list-style: circle;
}
-blockquote, q {
- quotes: none;
-}
-
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
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 6027e21bb42..8034a8e54b8 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
@@ -616,7 +616,10 @@ ActionManager.include({
// determine the action to execute according to the actionData
if (actionData.special) {
- def = Promise.resolve({type: 'ir.actions.act_window_close', infos: 'special'});
+ def = Promise.resolve({
+ type: 'ir.actions.act_window_close',
+ infos: { special: true },
+ });
} else if (actionData.type === 'object') {
// call a Python Object method, which may return an action to execute
var args = recordID ? [[recordID]] : [env.resIDs];
diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js
index d2fdb41fefc..a61caf480e4 100644
--- a/addons/web/static/src/js/core/dom.js
+++ b/addons/web/static/src/js/core/dom.js
@@ -114,6 +114,7 @@ var dom = {
borderTopWidth: 0,
borderBottomWidth: 0,
padding: 0,
+ overflow: 'hidden',
});
$fixedTextarea.data("auto_resize", true);
diff --git a/addons/web/static/src/js/core/service_mixins.js b/addons/web/static/src/js/core/service_mixins.js
index f34adfe4de8..0576364fa7c 100644
--- a/addons/web/static/src/js/core/service_mixins.js
+++ b/addons/web/static/src/js/core/service_mixins.js
@@ -254,6 +254,7 @@ var ServicesMixin = {
* @see displayNotification
*/
do_warn: function (title, message, sticky, className) {
+ console.warn(title, message);
return this.displayNotification({
type: 'danger',
title: title,
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index c1d01022c68..4590f490932 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -588,7 +588,7 @@ var FieldDateRange = InputField.extend({
timePicker: !this.isDateField,
timePicker24Hour: _t.database.parameters.time_format.search('%H') !== -1,
autoUpdateInput: false,
- timePickerIncrement: 10,
+ timePickerIncrement: 5,
locale: {
format: this.isDateField ? time.getLangDateFormat() : time.getLangDatetimeFormat(),
},
@@ -1166,7 +1166,6 @@ var FieldFloatTime = FieldFloat.extend({
});
var FieldFloatFactor = FieldFloat.extend({
- description: "",
supportedFieldTypes: ['float'],
className: 'o_field_float_factor',
formatType: 'float_factor',
@@ -1371,6 +1370,7 @@ var FieldText = InputField.extend(TranslatableFieldMixin, {
*/
_onKeydown: function (ev) {
if (ev.which === $.ui.keyCode.ENTER) {
+ ev.stopPropagation();
return;
}
this._super.apply(this, arguments);
@@ -3271,11 +3271,22 @@ var AceEditor = DebouncedField.extend({
*/
var FieldColor = AbstractField.extend({
template: 'FieldColor',
- events: {
+ events: _.extend({}, AbstractField.prototype.events, {
'click .o_field_color': '_onColorClick',
- },
- custom_events: {
+ }),
+ custom_events: _.extend({}, AbstractField.prototype.custom_events, {
'colorpicker:saved': '_onColorpickerSaved',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getFocusableElement: function () {
+ return this.$('.o_field_color');
},
//--------------------------------------------------------------------------
@@ -3287,10 +3298,10 @@ var FieldColor = AbstractField.extend({
* @private
*/
_render: function () {
- this._super.apply(this, arguments);
this.$('.o_field_color').data('value', this.value)
.css('background-color', this.value)
.attr('title', this.value);
+ return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
@@ -3299,13 +3310,20 @@ var FieldColor = AbstractField.extend({
/**
* @private
- * @param {MouseEvent} ev
*/
- _onColorClick: function (ev) {
- new ColorpickerDialog(this, {
- defaultColor: this.value,
- noTransparency: true,
- }).open();
+ _onColorClick: function () {
+ if (this.mode === 'edit') {
+ const dialog = new ColorpickerDialog(this, {
+ defaultColor: this.value,
+ noTransparency: true,
+ }).open();
+ dialog.on('closed', this, () => {
+ // we need to wait for the modal to execute its whole close function.
+ Promise.resolve().then(() => {
+ this.getFocusableElement().focus();
+ });
+ });
+ }
},
/**
@@ -3315,6 +3333,20 @@ var FieldColor = AbstractField.extend({
_onColorpickerSaved: function (ev) {
this._setValue(ev.data.hex);
},
+
+ /**
+ * @override
+ * @private
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._onColorClick(ev);
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
});
return {
diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js
index 782f76d6b10..f653ded7181 100644
--- a/addons/web/static/src/js/fields/field_utils.js
+++ b/addons/web/static/src/js/fields/field_utils.js
@@ -415,7 +415,7 @@ function parseDate(value, field, options) {
if (options && options.isUTC) {
date = moment.utc(value);
} else {
- date = moment.utc(value, [datePattern, datePatternWoZero, moment.ISO_8601], true);
+ date = moment.utc(value, [datePattern, datePatternWoZero, moment.ISO_8601]);
}
if (date.isValid()) {
if (date.year() === 0) {
@@ -459,7 +459,7 @@ function parseDateTime(value, field, options) {
// phatomjs crash if we don't use this format
datetime = moment.utc(value.replace(' ', 'T') + 'Z');
} else {
- datetime = moment.utc(value, [pattern1, pattern2, moment.ISO_8601], true);
+ datetime = moment.utc(value, [pattern1, pattern2, moment.ISO_8601]);
if (options && options.timezone) {
datetime.add(-session.getTZOffset(datetime), 'minutes');
}
@@ -662,6 +662,7 @@ return {
integer: formatInteger,
many2many: formatX2Many,
many2one: formatMany2one,
+ many2one_reference: formatInteger,
monetary: formatMonetary,
one2many: formatX2Many,
percentage: formatPercentage,
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index b89900c9b3b..2e14ee56e8d 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -726,8 +726,13 @@ var FieldMany2One = AbstractField.extend({
readonly: !self.can_write,
on_saved: function (record, changed) {
if (changed) {
- self._setValue(self.value.data, {forceChange: true}).then(function() {
- self.trigger_up('reload', {db_id: self.value.id});
+ const _setValue = self._setValue.bind(self, self.value.data, {
+ forceChange: true,
+ });
+ self.trigger_up('reload', {
+ db_id: self.value.id,
+ onSuccess: _setValue,
+ onFailure: _setValue,
});
}
},
@@ -817,7 +822,9 @@ var FieldMany2One = AbstractField.extend({
});
var Many2oneBarcode = FieldMany2One.extend({
- description: "",
+ // We don't require this widget to be displayed in studio sidebar in
+ // non-debug mode hence just extended it from its original widget, so that
+ // description comes from parent and hasOwnProperty based condition fails
});
var ListFieldMany2One = FieldMany2One.extend({
@@ -826,6 +833,8 @@ var ListFieldMany2One = FieldMany2One.extend({
}),
/**
+ * Should never be allowed to be opened while in readonly mode in a list
+ *
* @override
*/
init: function () {
@@ -1600,7 +1609,9 @@ var FieldX2Many = AbstractField.extend({
_onActiveNextWidget: function (e) {
e.stopPropagation();
this.renderer.unselectRow();
- this.trigger_up('navigation_move',{direction:'next'});
+ this.trigger_up('navigation_move', {
+ direction: e.data.direction || 'next',
+ });
},
});
diff --git a/addons/web/static/src/js/fields/special_fields.js b/addons/web/static/src/js/fields/special_fields.js
index cfadd9f101a..ab040daa4f0 100644
--- a/addons/web/static/src/js/fields/special_fields.js
+++ b/addons/web/static/src/js/fields/special_fields.js
@@ -16,7 +16,6 @@ var _t = core._t;
* the time difference between UTC time and local time, in minutes.
*/
var FieldTimezoneMismatch = FieldSelection.extend({
- description: "",
/**
* @override
*/
@@ -129,7 +128,6 @@ var FieldTimezoneMismatch = FieldSelection.extend({
var FieldReportLayout = relational_fields.FieldMany2One.extend({
// this widget is not generic, so we disable its studio use
// supportedFieldTypes: ['many2one', 'selection'],
- description: "",
events: _.extend({}, relational_fields.FieldMany2One.prototype.events, {
'click img': '_onImgClicked',
}),
diff --git a/addons/web/static/src/js/libs/bootstrap.js b/addons/web/static/src/js/libs/bootstrap.js
index b91728b22d1..8f200794fa8 100644
--- a/addons/web/static/src/js/libs/bootstrap.js
+++ b/addons/web/static/src/js/libs/bootstrap.js
@@ -6,11 +6,68 @@ odoo.define('web.bootstrap.extensions', function () {
* patching in place.
*/
+/**
+ * Review Bootstrap Sanitization: leave it enabled by default but extend it to
+ * accept more common tag names like tables and buttons, and common attributes
+ * such as style or data-. If a specific tooltip or popover must accept custom
+ * tags or attributes, they must be supplied through the whitelist BS
+ * parameter explicitely.
+ *
+ * We cannot disable sanitization because bootstrap uses tooltip/popover
+ * DOM attributes in an "unsafe" way.
+ */
+var bsSanitizeWhiteList = $.fn.tooltip.Constructor.Default.whiteList;
+
+bsSanitizeWhiteList['*'].push('title', 'style', /^data-[\w-]+/);
+
+bsSanitizeWhiteList.header = [];
+bsSanitizeWhiteList.main = [];
+bsSanitizeWhiteList.footer = [];
+
+bsSanitizeWhiteList.caption = [];
+bsSanitizeWhiteList.col = ['span'];
+bsSanitizeWhiteList.colgroup = ['span'];
+bsSanitizeWhiteList.table = [];
+bsSanitizeWhiteList.thead = [];
+bsSanitizeWhiteList.tbody = [];
+bsSanitizeWhiteList.tfooter = [];
+bsSanitizeWhiteList.tr = [];
+bsSanitizeWhiteList.th = ['colspan', 'rowspan'];
+bsSanitizeWhiteList.td = ['colspan', 'rowspan'];
+
+bsSanitizeWhiteList.address = [];
+bsSanitizeWhiteList.article = [];
+bsSanitizeWhiteList.aside = [];
+bsSanitizeWhiteList.blockquote = [];
+bsSanitizeWhiteList.section = [];
+
+bsSanitizeWhiteList.button = ['type'];
+bsSanitizeWhiteList.del = [];
+
+/**
+ * Returns an extended version of bootstrap default whitelist for sanitization,
+ * i.e. a version where, for each key, the original value is concatened with the
+ * received version's value and where the received version's extra key/values
+ * are added.
+ *
+ * Note: the returned version
+ *
+ * @param {Object} extensions
+ * @returns {Object} /!\ the returned whitelist is made from a *shallow* copy of
+ * the default whitelist, extended with given whitelist.
+ */
+function makeExtendedSanitizeWhiteList(extensions) {
+ var whiteList = _.clone($.fn.tooltip.Constructor.Default.whiteList);
+ Object.keys(extensions).forEach(key => {
+ whiteList[key] = (whiteList[key] || []).concat(extensions[key]);
+ });
+ return whiteList;
+}
+
/* Bootstrap tooltip defaults overwrite */
$.fn.tooltip.Constructor.Default.placement = 'auto';
$.fn.tooltip.Constructor.Default.fallbackPlacement = ['bottom', 'right', 'left', 'top'];
$.fn.tooltip.Constructor.Default.html = true;
-$.fn.tooltip.Constructor.Default.sanitize = false;
$.fn.tooltip.Constructor.Default.trigger = 'hover';
$.fn.tooltip.Constructor.Default.container = 'body';
$.fn.tooltip.Constructor.Default.boundary = 'window';
@@ -25,7 +82,7 @@ $.fn.tooltip.Constructor.prototype.show = function () {
return bootstrapShowFunction.call(this);
};
-/* Bootstrap popover defaults overwrite */
-$.fn.popover.Constructor.Default.sanitize = false;
-
+return {
+ makeExtendedSanitizeWhiteList: makeExtendedSanitizeWhiteList,
+};
});
diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js
index 3e2601b037f..8bbdd2bbe1b 100644
--- a/addons/web/static/src/js/tools/debug_manager.js
+++ b/addons/web/static/src/js/tools/debug_manager.js
@@ -3,6 +3,7 @@ odoo.define('web.DebugManager', function (require) {
var core = require('web.core');
var session = require('web.session');
+var utils = require('web.utils');
var Widget = require('web.Widget');
var QWeb = core.qweb;
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index be0b9a00bf3..7639d2dea99 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -404,10 +404,13 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
options = options || {};
return this.canBeDiscarded(recordID)
.then(function (needDiscard) {
- if (options.noAbandon || (options.readonlyIfRealDiscard && !needDiscard)) {
+ if (options.readonlyIfRealDiscard && !needDiscard) {
return;
}
self.model.discardChanges(recordID);
+ if (options.noAbandon) {
+ return;
+ }
if (self.model.canBeAbandoned(recordID)) {
self._abandonRecord(recordID);
return;
@@ -702,21 +705,25 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
* re-render (reload the whole form by default)
* @param {string[]} [ev.data.fieldNames] list of the record's fields to
* reload
+ * @param {Function} [ev.data.onSuccess] callback executed after reload is resolved
+ * @param {Function} [ev.data.onFailure] callback executed when reload is rejected
*/
_onReload: function (ev) {
ev.stopPropagation(); // prevent other controllers from handling this request
var data = ev && ev.data || {};
var handle = data.db_id;
+ var prom;
if (handle) {
// reload the relational field given its db_id
- this.model.reload(handle).then(this._confirmSave.bind(this, handle));
+ prom = this.model.reload(handle).then(this._confirmSave.bind(this, handle));
} else {
// no db_id given, so reload the main record
- this.reload({
+ prom = this.reload({
fieldNames: data.fieldNames,
keepChanges: data.keepChanges || false,
});
}
+ prom.then(ev.data.onSuccess).guardedCatch(ev.data.onFailure);
},
/**
* Resequence records in the given order.
@@ -833,7 +840,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
userLanguageValue: ev.target.value || '',
dataPointID: record.id,
isComingFromTranslationAlert: ev.data.isComingFromTranslationAlert,
- isText: record.fields[ev.data.fieldName].type === 'text',
+ isText: result.context.translation_type === 'text',
+ showSrc: result.context.translation_show_src,
});
return this.translationDialog.open();
},
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 4b4d02c2755..d4e966440cb 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -94,6 +94,9 @@ var viewUtils = require('web.viewUtils');
var _t = core._t;
+// field types that can be aggregated in grouped views
+const AGGREGATABLE_TYPES = ['float', 'integer', 'monetary'];
+
var x2ManyCommands = {
// (0, virtualID, {values})
CREATE: 0,
@@ -3353,7 +3356,15 @@ var BasicModel = AbstractModel.extend({
var viewType = view ? view.type : fieldInfo.viewType;
var toFetch = {};
- _.each(list.data, function (dataPoint) {
+
+ // flattens the list.data ids in a grouped case
+ let dataPointIds = list.data;
+ for (let i = 0; i < list.groupedBy.length; i++) {
+ dataPointIds = dataPointIds.reduce((acc, groupId) =>
+ acc.concat(this.localData[groupId].data), []);
+ }
+
+ dataPointIds.forEach(function (dataPoint) {
var record = self.localData[dataPoint];
if (typeof record.data[fieldName] === 'string'){
// in this case, the value is a local ID, which means that the
@@ -4451,8 +4462,9 @@ var BasicModel = AbstractModel.extend({
_.each(groups, function (group) {
var aggregateValues = {};
_.each(group, function (value, key) {
- if (_.contains(fields, key) && key !== groupByField) {
- aggregateValues[key] = value;
+ if (_.contains(fields, key) && key !== groupByField &&
+ AGGREGATABLE_TYPES.includes(list.fields[key].type)) {
+ aggregateValues[key] = value;
}
});
// When a view is grouped, we need to display the name of each group in
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index 38d2f220c2f..5cb293e866b 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -282,29 +282,28 @@ var BasicView = AbstractView.extend({
});
}
+ attrs.views = attrs.views || {};
+
+ // Keep compatibility with 'tree' syntax
+ attrs.mode = attrs.mode === 'tree' ? 'list' : attrs.mode;
+ if (!attrs.views.list && attrs.views.tree) {
+ attrs.views.list = attrs.views.tree;
+ }
+
if (field.type === 'one2many' || field.type === 'many2many') {
if (attrs.Widget.prototype.useSubview) {
- if (!attrs.views) {
- attrs.views = {};
- }
var mode = attrs.mode;
if (!mode) {
- if (attrs.views.tree && attrs.views.kanban) {
- mode = 'tree';
- } else if (!attrs.views.tree && attrs.views.kanban) {
+ if (attrs.views.list && !attrs.views.kanban) {
+ mode = 'list';
+ } else if (!attrs.views.list && attrs.views.kanban) {
mode = 'kanban';
} else {
- mode = 'tree,kanban';
+ mode = 'list,kanban';
}
}
if (mode.indexOf(',') !== -1) {
- mode = config.device.isMobile ? 'kanban' : 'tree';
- }
- if (mode === 'tree') {
- mode = 'list';
- if (!attrs.views.list && attrs.views.tree) {
- attrs.views.list = attrs.views.tree;
- }
+ mode = config.device.isMobile ? 'kanban' : 'list';
}
attrs.mode = mode;
if (mode in attrs.views) {
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
index 22ff7bb211d..bafbe822670 100644
--- a/addons/web/static/src/js/views/calendar/calendar_model.js
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -26,6 +26,10 @@ return AbstractModel.extend({
init: function () {
this._super.apply(this, arguments);
this.end_date = null;
+ var week_start = _t.database.parameters.week_start;
+ // calendar uses index 0 for Sunday but Odoo stores it as 7
+ this.week_start = week_start !== undefined && week_start !== false ? week_start % 7 : moment().startOf('week').day();
+ this.week_stop = this.week_start + 6;
},
//--------------------------------------------------------------------------
@@ -277,12 +281,25 @@ return AbstractModel.extend({
switch (this.data.scale) {
case 'month':
- this.data.start_date = this.data.start_date.clone().startOf('month').startOf('week');
- this.data.end_date = this.data.start_date.clone().add(5, 'week').endOf('week');
+ var monthStart = this.data.start_date.clone().startOf('month');
+
+ var monthStartDay;
+ if (monthStart.day() >= this.week_start) {
+ // the month's first day is after our week start
+ // Then we are in the right week
+ monthStartDay = this.week_start;
+ } else {
+ // The month's first day is before our week start
+ // Then we should go back to the the previous week
+ monthStartDay = this.week_start - 7;
+ }
+
+ this.data.start_date = monthStart.day(monthStartDay).startOf('day');
+ this.data.end_date = this.data.start_date.clone().add(5, 'week').day(this.week_stop).endOf('day');
break;
case 'week':
- this.data.start_date = this.data.start_date.clone().startOf('week');
- this.data.end_date = this.data.end_date.clone().endOf('week');
+ this.data.start_date = this.data.start_date.clone().day(this.week_start).startOf('day');
+ this.data.end_date = this.data.end_date.clone().day(this.week_stop).endOf('day');
break;
default:
this.data.start_date = this.data.start_date.clone().startOf('day');
@@ -388,9 +405,6 @@ return AbstractModel.extend({
* @returns {Object}
*/
_getFullCalendarOptions: function () {
- var week_start = _t.database.parameters.week_start || 0;
- // calendar uses index 0 for Sunday but Odoo stores it as 7
- week_start = week_start % 7;
return {
defaultView: (this.mode === "month")? "month" : ((this.mode === "week")? "agendaWeek" : ((this.mode === "day")? "agendaDay" : "agendaWeek")),
header: false,
@@ -412,7 +426,7 @@ return AbstractModel.extend({
monthNamesShort: moment.monthsShort(),
dayNames: moment.weekdays(),
dayNamesShort: moment.weekdaysShort(),
- firstDay: week_start,
+ firstDay: this.week_start,
slotLabelFormat: _t.database.parameters.time_format.search("%H") != -1 ? 'H:mm': 'h(:mm)a',
};
},
diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js
index 757835e694d..b28d0622094 100644
--- a/addons/web/static/src/js/views/calendar/calendar_renderer.js
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -57,6 +57,7 @@ var SidebarFilter = Widget.extend(FieldManagerMixin, {
this.filters = options.filters;
this.label = options.label;
this.getColor = options.getColor;
+ this.isSwipeEnabled = true;
},
/**
* @override
@@ -325,6 +326,9 @@ return AbstractRenderer.extend({
});
this.$calendar.on('touchend', function (event) {
touchEndX = event.originalEvent.changedTouches[0].pageX;
+ if (!self.isSwipeEnabled) {
+ return;
+ }
if (touchStartX - touchEndX > 100) {
self.trigger_up('next');
} else if (touchStartX - touchEndX < -100) {
@@ -393,6 +397,7 @@ return AbstractRenderer.extend({
self._renderEventPopover(eventData, $(ev.currentTarget));
},
select: function (startDate, endDate) {
+ self.isSwipeEnabled = false;
// Clicking on the view, dispose any visible popover. Otherwise create a new event.
if (self.$('.o_cw_popover').length) {
self._unselectEvent();
@@ -406,6 +411,7 @@ return AbstractRenderer.extend({
self.$calendar.fullCalendar('unselect');
},
eventRender: function (event, element, view) {
+ self.isSwipeEnabled = false;
var $render = $(self._eventRender(event));
element.find('.fc-content').html($render.html());
element.addClass($render.attr('class'));
@@ -433,6 +439,9 @@ return AbstractRenderer.extend({
self.trigger_up('edit_event', {id: event.id});
});
},
+ eventAfterAllRender: function () {
+ self.isSwipeEnabled = true;
+ },
viewRender: function (view) {
// compute mode from view.name which is either 'month', 'agendaWeek' or 'agendaDay'
var mode = view.name === 'month' ? 'month' : (view.name === 'agendaWeek' ? 'week' : 'day');
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_model.js b/addons/web/static/src/js/views/control_panel/control_panel_model.js
index 836a4eceb59..15d2f7487ae 100644
--- a/addons/web/static/src/js/views/control_panel/control_panel_model.js
+++ b/addons/web/static/src/js/views/control_panel/control_panel_model.js
@@ -234,7 +234,7 @@ var ControlPanelModel = mvc.Model.extend({
if (filter.type === 'field') {
filterFields.push(filter);
}
- if (filter.type === 'filter') {
+ if (filter.type === 'filter' && !filter.invisible) {
filters.push(filter);
}
if (filter.type === 'groupBy') {
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_view.js b/addons/web/static/src/js/views/control_panel/control_panel_view.js
index dd2cf268ee4..5cd07ea117d 100644
--- a/addons/web/static/src/js/views/control_panel/control_panel_view.js
+++ b/addons/web/static/src/js/views/control_panel/control_panel_view.js
@@ -15,7 +15,6 @@ var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD;
var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
const OPTION_GENERATORS = controlPanelViewParameters.OPTION_GENERATORS;
-const YEAR_OPTIONS = controlPanelViewParameters.YEAR_OPTIONS;
var Factory = mvc.Factory;
@@ -38,6 +37,8 @@ var ControlPanelView = Factory.extend({
* @param {Object} [params.state] used to determine the control panel model
* essential content at load. For instance, state can be the state of an
* other control panel model that we want to use.
+ * @param {string} [params.title] the name of the action, to display in the
+ * breadcrumb
* @param {string} [params.template] the QWeb template to render
* @param {Object} [params.viewInfo={arch: '<search/>', fields: {}}] a
* search fieldsview
@@ -99,6 +100,7 @@ var ControlPanelView = Factory.extend({
this.rendererParams.context = context;
this.rendererParams.searchMenuTypes = params.searchMenuTypes || [];
this.rendererParams.template = params.template;
+ this.rendererParams.title = params.title;
this.rendererParams.withBreadcrumbs = params.withBreadcrumbs !== false;
this.rendererParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
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 95b5c577587..2110fcb2ca1 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
@@ -12,7 +12,7 @@ ControlPanelRenderer.include({
events:_.extend({}, ControlPanelRenderer.prototype.events, {
'click .o_mobile_search_close, .o_mobile_search_show_result, .o_toggle_searchview_full': '_toggleMobileSearchView',
'click .o_enable_searchview': '_toggleMobileQuickSearchView',
- 'click': '_onOpenMobileSearchView',
+ 'click .o_toggle_searchview_full': '_onOpenMobileSearchView',
'click .o_mobile_search_clear_facets': '_onEmptyAll',
'show.bs.dropdown .o_mobile_search_filter .o_dropdown': '_onDropdownToggle',
'hide.bs.dropdown .o_mobile_search_filter .o_dropdown': '_onDropdownToggle',
diff --git a/addons/web/static/src/js/views/control_panel/search/search_filters.js b/addons/web/static/src/js/views/control_panel/search/search_filters.js
index ae0ed12772e..7a6d605a9b7 100644
--- a/addons/web/static/src/js/views/control_panel/search/search_filters.js
+++ b/addons/web/static/src/js/views/control_panel/search/search_filters.js
@@ -184,13 +184,13 @@ var DateTime = Field.extend({
type: 'datetime'
},
operators: [
+ {value: "between", text: _lt("is between")},
{value: "=", text: _lt("is equal to")},
{value: "!=", text: _lt("is not equal to")},
{value: ">", text: _lt("is after")},
{value: "<", text: _lt("is before")},
{value: ">=", text: _lt("is after or equal to")},
{value: "<=", text: _lt("is before or equal to")},
- {value: "between", text: _lt("is between")},
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
@@ -216,15 +216,9 @@ var DateTime = Field.extend({
this._super.apply(this, arguments);
if ($operator.val() === "between") {
- if (!this.datewidget_1) {
- this._create_new_widget("datewidget_1");
- } else {
- this.datewidget_1.do_show();
- }
+ this.datewidget_1.do_show();
} else {
- if (this.datewidget_1) {
- this.datewidget_1.do_hide();
- }
+ this.datewidget_1.do_hide();
}
},
toString: function () {
@@ -238,13 +232,18 @@ var DateTime = Field.extend({
start: function () {
return Promise.all([
this._super.apply(this, arguments),
- this._create_new_widget("datewidget_0")
- ]);
+ this._create_new_widget("datewidget_0", '00:00:00', 'hh:mm:ss'),
+ this._create_new_widget("datewidget_1", '23:59:59', 'hh:mm:ss'),
+ ]).then(() => {
+ if (this.operators[0].value !== "between") {
+ this.datewidget_1.do_hide();
+ }
+ });
},
- _create_new_widget: function (name) {
+ _create_new_widget: function (name, ...time) {
this[name] = new (this._get_widget_class())(this);
return this[name].appendTo(this.$el).then((function () {
- this[name].setValue(moment());
+ this[name].setValue(moment(...time));
}).bind(this));
},
_get_widget_class: function () {
@@ -256,6 +255,17 @@ var Date = DateTime.extend({
attributes: {
type: 'date'
},
+ operators: [
+ {value: "=", text: _lt("is equal to")},
+ {value: "!=", text: _lt("is not equal to")},
+ {value: ">", text: _lt("is after")},
+ {value: "<", text: _lt("is before")},
+ {value: ">=", text: _lt("is after or equal to")},
+ {value: "<=", text: _lt("is before or equal to")},
+ {value: "between", text: _lt("is between")},
+ {value: "∃", text: _lt("is set")},
+ {value: "∄", text: _lt("is not set")}
+ ],
get_value: function (index) {
// retrieve the datepicker value
return this["datewidget_" + (index || 0)].getValue();
diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js
index b4aecd7a236..0facd099370 100644
--- a/addons/web/static/src/js/views/kanban/kanban_column.js
+++ b/addons/web/static/src/js/views/kanban/kanban_column.js
@@ -59,6 +59,7 @@ var KanbanColumn = Widget.extend({
this.KanbanRecord = options.KanbanRecord || KanbanRecord; // the KanbanRecord class to use
this.records_editable = options.records_editable;
this.records_deletable = options.records_deletable;
+ this.recordsDraggable = options.recordsDraggable;
this.relation = options.relation;
this.offset = 0;
this.remaining = data.count - this.data_records.length;
@@ -100,7 +101,7 @@ var KanbanColumn = Widget.extend({
defs.push(this._addRecord(this.data_records[i]));
}
- if (!config.device.isMobile) {
+ if (this.recordsDraggable && !config.device.isMobile) {
// deactivate sortable in mobile mode. It does not work anyway,
// and it breaks horizontal scrolling in kanban views. Someday, we
// should find a way to use the touch events to make sortable work.
diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js
index 62205a2fc46..d265ca6ae6b 100644
--- a/addons/web/static/src/js/views/kanban/kanban_view.js
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -65,6 +65,7 @@ var KanbanView = BasicView.extend({
archivable: archAttrs.archivable ? !!JSON.parse(archAttrs.archivable) : true,
group_creatable: activeActions.group_create && !config.device.isMobile,
quickCreateView: archAttrs.quick_create_view || null,
+ recordsDraggable: archAttrs.records_draggable ? !!JSON.parse(archAttrs.records_draggable) : true,
hasProgressBar: !!progressBar,
};
this.rendererParams.record_options = {
diff --git a/addons/web/static/src/js/views/list/list_confirm_dialog.js b/addons/web/static/src/js/views/list/list_confirm_dialog.js
new file mode 100644
index 00000000000..8ed07a73ca0
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_confirm_dialog.js
@@ -0,0 +1,78 @@
+odoo.define('web.ListConfirmDialog', function (require) {
+"use strict";
+
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+
+const _t = core._t;
+const qweb = core.qweb;
+
+/**
+ * Multi edition confirmation modal for list views.
+ *
+ * Handles the display of the amount of changed records (+ valid ones) and
+ * of the widget representing the new value.
+ *
+ * @class
+ */
+const ListConfirmDialog = Dialog.extend({
+ /**
+ * @constructor
+ * @override
+ * @param {Widget} parent
+ * @param {Object} record edited record with updated value
+ * @param {Object} changes changes registered by the list controller
+ * @param {string} changes.fieldLabel label of the changed field
+ * @param {string} changes.fieldName technical name of the changed field
+ * @param {number} changes.nbRecords number of records (total)
+ * @param {number} changes.nbValidRecords number of valid records
+ * @param {Object} [options]
+ */
+ init: function (parent, record, changes, options) {
+ options = Object.assign({}, options, {
+ $content: $(qweb.render('ListView.confirmModal', { changes })),
+ buttons: options.buttons || [{
+ text: _t("Ok"),
+ classes: 'btn-primary',
+ close: true,
+ click: options.confirm_callback,
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ click: options.cancel_callback,
+ }],
+ onForceClose: options.cancel_callback,
+ size: options.size || 'medium',
+ title: options.title || _t("Confirmation"),
+ });
+
+ this._super(parent, options);
+
+ const Widget = record.fieldsInfo.list[changes.fieldName].Widget;
+ this.fieldWidget = new Widget(this, changes.fieldName, record, {
+ mode: 'readonly',
+ viewType: 'list',
+ noOpen: true,
+ });
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([
+ this.fieldWidget.appendTo(document.createDocumentFragment()),
+ this._super.apply(this, arguments)
+ ]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$content.find('.o_changes_widget').replaceWith(this.fieldWidget.$el);
+ return this._super.apply(this, arguments);
+ },
+});
+
+return ListConfirmDialog;
+
+});
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 4e0060fa5ff..fbd5e4a2af0 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -8,69 +8,15 @@ 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 ListConfirmDialog = require('web.ListConfirmDialog');
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
@@ -78,7 +24,7 @@ var ListController = BasicController.extend({
*/
buttons_template: 'ListView.buttons',
events: _.extend({}, BasicController.prototype.events, {
- 'click .o_list_download': '_onExportRecords',
+ 'click .o_list_export_xlsx': '_onDirectExportData',
}),
custom_events: _.extend({}, BasicController.prototype.custom_events, {
activate_next_widget: '_onActivateNextWidget',
@@ -109,8 +55,9 @@ var ListController = BasicController.extend({
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,
+ Object.defineProperty(this, 'mode', {
+ get: () => this.renderer.isEditable() ? 'edit' : 'readonly',
+ set: () => {},
});
},
@@ -130,14 +77,11 @@ var ListController = BasicController.extend({
* @returns {Promise<array[]>} a promise that resolve to the active domain
*/
getActiveDomain: function () {
- // TODO: this method should be synchronous...
var self = this;
if (this.$('thead .o_list_record_selector input').prop('checked')) {
var searchQuery = this._controlPanel ? this._controlPanel.getSearchQuery() : {};
var record = self.model.get(self.handle, {raw: true});
- return Promise.all(record.getDomain().concat(searchQuery.domain || []));
- } else {
- return Promise.resolve();
+ return record.getDomain().concat(searchQuery.domain || []);
}
},
/*
@@ -402,29 +346,15 @@ var ListController = BasicController.extend({
});
},
/**
- * Export the current list data in a xls file.
- *
+ * @returns {DataExport} the export dialog widget
* @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),
- });
+ _getExportDialogWidget() {
+ let state = this.model.get(this.handle);
+ let defaultExportFields = this.renderer.columns.filter(field => field.tag === 'field').map(field => field.attrs.name);
+ let groupedBy = this.renderer.state.groupedBy;
+ return new DataExport(this, state, defaultExportFields, groupedBy,
+ this.getActiveDomain(), this.getSelectedIds());
},
/**
* @override
@@ -458,62 +388,54 @@ var ListController = BasicController.extend({
* @returns {Promise}
*/
_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) {
- var record = self.model.get(nextRecordId);
- var modifiers = self.renderer._registerModifiers(node, record);
+ var validRecordIds = recordIds.reduce((result, nextRecordId) => {
+ var record = this.model.get(nextRecordId);
+ var modifiers = this.renderer._registerModifiers(node, record);
if (!modifiers.readonly && (!modifiers.required || value)) {
result.push(nextRecordId);
}
return result;
}, []);
- const nbInvalid = recordIds.length - validRecordIds.length;
-
return new Promise((resolve, reject) => {
- const rejectAndDiscard = () => {
+ const discardAndReject = () => {
this.model.discardChanges(recordId);
- return this._confirmSave(recordId).then(reject);
+ this._confirmSave(recordId).then(() => {
+ this.renderer.focusCell(recordId, node);
+ reject();
+ });
};
- let dialog;
if (validRecordIds.length > 0) {
- let message;
- if (nbInvalid === 0) {
- message = _.str.sprintf(
- _t("Do you want to set the value on the %d selected records?"),
- validRecordIds.length);
- } else {
- message = _.str.sprintf(
- _t("Do you want to set the value on the %d valid selected records? (%d invalid)"),
- validRecordIds.length, nbInvalid);
- }
- dialog = Dialog.confirm(this, message, {
+ const dialogOptions = {
confirm_callback: () => {
- return this.model.saveRecords(recordId, validRecordIds, fieldName)
+ this.model.saveRecords(this.handle, recordId, validRecordIds, fieldName)
.then(async () => {
this._updateButtons('readonly');
const state = this.model.get(this.handle);
- await this.renderer.updateState(state, {keepWidths: true});
+ await this.renderer.updateState(state, { keepWidths: true });
+ await this.renderer.focusCell(recordId, node);
resolve(Object.keys(changes));
})
- .guardedCatch(rejectAndDiscard);
+ .guardedCatch(discardAndReject);
},
- cancel_callback: rejectAndDiscard,
- });
+ cancel_callback: discardAndReject,
+ };
+ const record = this.model.get(recordId);
+ const dialogChanges = {
+ fieldLabel: node.attrs.string || record.fields[fieldName].string,
+ fieldName: node.attrs.name,
+ nbRecords: recordIds.length,
+ nbValidRecords: validRecordIds.length,
+ };
+ new ListConfirmDialog(this, record, dialogChanges, dialogOptions)
+ .open({ shouldFocusButtons: true });
} else {
- dialog = Dialog.alert(this, _t("No valid record to save"), {
- confirm_callback: rejectAndDiscard,
+ Dialog.alert(this, _t("No valid record to save"), {
+ confirm_callback: discardAndReject,
});
}
- 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);
- });
});
},
/**
@@ -524,7 +446,7 @@ var ListController = BasicController.extend({
*/
_saveRecord: function (recordId) {
var record = this.model.get(recordId, { raw: true });
- if (record.isDirty() && this.renderer.inMultipleRecordEdition(recordId)) {
+ if (record.isDirty() && this.renderer.isInMultipleRecordEdition(recordId)) {
// do not save the record (see _saveMultipleRecords)
const prom = this.multipleRecordsSavingPromise || Promise.reject();
this.multipleRecordsSavingPromise = null;
@@ -567,7 +489,7 @@ var ListController = BasicController.extend({
_toggleCreateButton: function () {
if (this.$buttons) {
var state = this.model.get(this.handle);
- var createHidden = this.editable && state.groupedBy.length && state.data.length;
+ var createHidden = this.renderer.isEditable() && state.groupedBy.length && state.data.length;
this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden);
}
},
@@ -599,6 +521,12 @@ var ListController = BasicController.extend({
_updateButtons: function (mode) {
if (this.$buttons) {
this.$buttons.toggleClass('o-editing', mode === 'edit');
+ const state = this.model.get(this.handle, {raw: true});
+ if (state.count) {
+ this.$('.o_list_export_xlsx').show();
+ } else {
+ this.$('.o_list_export_xlsx').hide();
+ }
}
},
@@ -615,7 +543,7 @@ var ListController = BasicController.extend({
*/
_onActivateNextWidget: function (ev) {
ev.stopPropagation();
- this.renderer.editFirstRecord();
+ this.renderer.editFirstRecord(ev);
},
/**
* Add a record to the list
@@ -726,19 +654,15 @@ var ListController = BasicController.extend({
* @private
*/
_onExportData: function () {
- var record = this.model.get(this.handle);
- var defaultExportFields = _.map(this.renderer.columns, function (field) {
- return field.attrs.name;
- });
- new DataExport(this, record, defaultExportFields).open();
+ this._getExportDialogWidget().open();
},
/**
* Export Records in a xls file
*
* @private
*/
- _onExportRecords() {
- this._downloadList()
+ _onDirectExportData() {
+ this._getExportDialogWidget().export();
},
/**
* Opens the related form view.
@@ -772,12 +696,14 @@ var ListController = BasicController.extend({
if (this.fieldChangedPrevented) {
this.fieldChangedPrevented = ev;
- } else if (this.renderer.inMultipleRecordEdition(recordId)) {
- // deal with edition of multiple lines
- ev.data.onSuccess = () => {
+ } else if (this.renderer.isInMultipleRecordEdition(recordId)) {
+ const saveMulti = () => {
this.multipleRecordsSavingPromise =
this._saveMultipleRecords(ev.data.dataPointID, ev.target.__node, ev.data.changes);
};
+ // deal with edition of multiple lines
+ ev.data.onSuccess = saveMulti; // will ask confirmation, and save
+ ev.data.onFailure = saveMulti; // will show the appropriate dialog
// disable onchanges as we'll save directly
ev.data.notifyChange = false;
}
@@ -814,14 +740,14 @@ var ListController = BasicController.extend({
* @param {OdooEvent} ev
*/
_onSetDirty: function (ev) {
- var self = this;
var recordId = ev.data.dataPointID;
- if (this.renderer.inMultipleRecordEdition(recordId)) {
+ if (this.renderer.isInMultipleRecordEdition(recordId)) {
ev.stopPropagation();
Dialog.alert(this, _t("No valid record to save"), {
- confirm_callback: function () {
- self.model.discardChanges(recordId);
- self._confirmSave(recordId);
+ confirm_callback: async () => {
+ this.model.discardChanges(recordId);
+ await this._confirmSave(recordId);
+ this.renderer.focusCell(recordId, ev.target.__node);
},
});
} else {
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 6f21653b70c..60e27998f18 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
@@ -39,12 +39,14 @@ ListRenderer.include({
* @param {boolean} params.addCreateLineInGroups
* @param {boolean} params.addTrashIcon
* @param {boolean} params.isMany2Many
+ * @param {boolean} params.isMultiEditable
*/
init: function (parent, state, params) {
var self = this;
this._super.apply(this, arguments);
this.editable = params.editable;
+ this.isMultiEditable = params.isMultiEditable;
this.columnWidths = false;
// if addCreateLine (resp. addCreateLineInGroups) is true, the renderer
@@ -99,7 +101,6 @@ ListRenderer.include({
this.currentRow = null;
this.currentFieldIndex = null;
- this.allRecordsIds = null; // flat array of records ids used by navigation
this.isResizing = false;
this.eventListeners = [];
},
@@ -108,10 +109,8 @@ ListRenderer.include({
* @returns {Promise}
*/
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));
- }
+ 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();
},
/**
@@ -134,9 +133,7 @@ ListRenderer.include({
*/
on_attach_callback: function () {
this.isInDOM = true;
- if (this.editable) {
- this._freezeColumnWidths();
- }
+ this._freezeColumnWidths();
this._super();
},
/**
@@ -308,8 +305,9 @@ ListRenderer.include({
/**
* Edit the first record in the list
*/
- editFirstRecord: function () {
- this._selectCell(this._getFirstDataRowIndex(), 0);
+ editFirstRecord: function (ev) {
+ const $borderRow = this._getBorderRow(ev.data.side || 'first');
+ this._selectCell($borderRow.prop('rowIndex') - 1, ev.data.cellIndex || 0);
},
/**
* Edit a given record in the list
@@ -351,8 +349,19 @@ ListRenderer.include({
* @param {string} recordId
* @returns {boolean}
*/
- inMultipleRecordEdition: function (recordId) {
- return this.editable && this.selection.length > 1 && this.selection.includes(recordId);
+ isInMultipleRecordEdition: function (recordId) {
+ return this.isEditable() && this.isMultiEditable && this.selection.includes(recordId);
+ },
+ /**
+ * Returns whether the list can be edited.
+ * It's true when:
+ * - the list `editable` property is set,
+ * - or at least one record is selected (becomes partially editable)
+ *
+ * @returns {boolean}
+ */
+ isEditable: function () {
+ return this.editable || (this.isMultiEditable && this.selection.length);
},
/**
* Removes the line associated to the given recordID (the index of the row
@@ -411,18 +420,6 @@ ListRenderer.include({
var $tds = $row.children('.o_data_cell');
var oldWidgets = _.clone(this.allFieldWidgets[record.id]);
- // When switching to edit mode, force the dimensions of all cells to
- // their current value so that they won't change if their content
- // changes, to prevent the view from flickering.
- // We need to use getBoundingClientRect instead of outerWidth to
- // prevent a rounding issue on Firefox.
- if (editMode) {
- $tds.each(function () {
- var $td = $(this);
- $td.css({width: $td[0].getBoundingClientRect().width});
- });
- }
-
// Prepare options for cell rendering (this depends on the mode)
var options = {
renderInvisible: editMode,
@@ -514,7 +511,7 @@ ListRenderer.include({
// 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.
- return changedFields && changedFields.length && this.inMultipleRecordEdition(recordID);
+ return changedFields && changedFields.length && this.isInMultipleRecordEdition(recordID);
}).guardedCatch(() => {
toggleWidgets(false);
});
@@ -527,9 +524,7 @@ ListRenderer.include({
// There are some cases where a record is added to an invisible list
// e.g. set a quotation template with optionnal products
if (params.keepWidths && this.$el.is(':visible')) {
- this.columnWidths = this.$('thead th').toArray().map(function (th) {
- return $(th).outerWidth();
- });
+ this._storeColumnWidths();
}
if (params.noRender) {
// the state changed, but we won't do a re-rendering right now, so
@@ -561,14 +556,6 @@ ListRenderer.include({
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.
*
@@ -582,6 +569,14 @@ ListRenderer.include({
}
},
/**
+ * When editing a row, we want to disable all record selectors.
+ *
+ * @private
+ */
+ _disableRecordSelectors: function () {
+ this.$('.o_list_record_selector input').attr('disabled', 'disabled');
+ },
+ /**
* @private
*/
_enableRecordSelectors: function () {
@@ -610,25 +605,56 @@ ListRenderer.include({
}
const table = this.el.getElementsByTagName('table')[0];
const thead = table.getElementsByTagName('thead')[0];
+ const thElements = [...thead.getElementsByTagName('th')];
- // 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;
- });
+ let columnWidths = this.columnWidths;
+ if (!columnWidths) { // no column widths to restore
+ // 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';
+ thElements.forEach((th) => {
+ th.style.width = null;
+ th.style.maxWidth = null;
+ });
+
+ // Squeeze the table by applying a max-width on largest columns to
+ // ensure that it doesn't overflow
+ columnWidths = this._squeezeTable();
+ }
- // Freeze each th width according to their size in auto layout
- 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`;
+ th.style.width = `${columnWidths[index]}px`;
});
// Set the table layout to fixed
table.style.tableLayout = 'fixed';
},
/**
+ * Returns the first or last editable row of the list
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getBorderRow: function (side) {
+ let $borderDataRow = this.$(`.o_data_row:${side}`);
+ if (!this._isRecordEditable($borderDataRow.data('id'))) {
+ $borderDataRow = this._getNearestEditableRow($borderDataRow, side === 'first');
+ }
+ return $borderDataRow;
+ },
+ /**
+ * Compute the sum of the weights for each columns, excluding
+ * those with an absolute width. param `$thread` is useful for studio, in
+ * order to show column hooks.
+ *
+ * @private
+ * @param {jQuery} $thead
+ * @return {integer}
+ */
+ _getColumnsTotalWidth($thead) {
+ return this.columns.reduce((acc, column) => acc + (column.attrs.relativeWidth || 0), 0);
+ },
+ /**
* Returns the width of a column according the 'width' attribute set in the
* arch, the widget or the field type. A fixed width is harcoded for some
* field types (e.g. date and numeric fields). By default, the remaining
@@ -664,7 +690,7 @@ ListRenderer.include({
return '1';
}
const fixedWidths = {
- boolean: '40px',
+ boolean: '50px',
date: '92px',
datetime: '146px',
float: '92px',
@@ -678,29 +704,40 @@ ListRenderer.include({
return fixedWidths[type] || '1';
},
/**
- * @private
+ * Returns the nearest editable row starting from a given table row.
+ * If the list is grouped, jumps to the next unfolded group
*
- * @returns {integer}
- */
- _getFirstDataRowIndex: function () {
- return this.$('.o_data_row:first').prop('rowIndex') - 1;
- },
- /**
- * Given a table row inside a group, returns the index of the first data
- * row of the next group (if any).
- *
- * @param {jQuery} $row this row must be inside a group
- * @returns {integer|null}
+ * @private
+ * @param {jQuery} $row starting point
+ * @param {boolean} next whether the requested row should be the next or the previous one
+ * @return {jQuery|null}
*/
- _getNextGroupFirstRowIndex: function ($row) {
- var $nextBody = $row.closest('tbody').next();
- while ($nextBody.length && !$nextBody.find('.o_data_row').length) {
- $nextBody = $nextBody.next();
- }
- if ($nextBody.find('.o_data_row').length) {
- return $nextBody.find('.o_data_row:first').prop('rowIndex') - 1;
+ _getNearestEditableRow: function ($row, next) {
+ const direction = next ? 'next' : 'prev';
+ let $nearestRow;
+ if (this.editable) {
+ $nearestRow = $row[direction]();
+ if (!$nearestRow.hasClass('o_data_row')) {
+ var $nextBody = $row.closest('tbody')[direction]();
+ while ($nextBody.length && !$nextBody.find('.o_data_row').length) {
+ $nextBody = $nextBody[direction]();
+ }
+ $nearestRow = $nextBody.find(`.o_data_row:${next ? 'first' : 'last'}`);
+ }
+ } else {
+ // In readonly lists, look directly into selected records
+ const recordId = $row.data('id');
+ const rowSelectionIndex = this.selection.indexOf(recordId);
+ let nextRowIndex;
+ if (rowSelectionIndex < 0) {
+ nextRowIndex = next ? 0 : this.selection.length - 1;
+ } else {
+ nextRowIndex = rowSelectionIndex + (next ? 1 : -1);
+ }
+ // Index might be out of range, will then return an empty jQuery object
+ $nearestRow = this._getRow(this.selection[nextRowIndex]);
}
- return null;
+ return $nearestRow;
},
/**
* Returns the current number of columns. The editable renderer may add a
@@ -776,104 +813,111 @@ ListRenderer.include({
}
},
/**
- * Move the cursor on the end of the previous line (or of the last line if
- * we are on the first one).
+ * Returns whether a recordID is currently editable.
+ *
+ * @param {string} recordID
+ * @returns {boolean}
+ */
+ _isRecordEditable: function (recordID) {
+ return this.editable || (this.isMultiEditable && this.selection.includes(recordID));
+ },
+ /**
+ * Moves to the next row in the list
*
* @private
+ * @params {Object} [options] see @_moveToSideLine
*/
- _moveToPreviousLine: function () {
- var self = this;
- if (!this.allRecordsIds) {
- // compute the flat array of all records ids only once
- this.allRecordsIds = [];
- utils.traverse_records(this.state, function (data) {
- self.allRecordsIds.push(data.id);
- });
- }
- var curRecordId = this._getRecordID(this.currentRow);
- var curRecordIndex = this.allRecordsIds.indexOf(curRecordId);
- var prevRecordIndex = curRecordIndex === 0 ? this.allRecordsIds.length - 1 : curRecordIndex - 1;
- this.commitChanges(curRecordId).then(function () {
- var $prevRow = self._getRow(self.allRecordsIds[prevRecordIndex]);
- var prevRowIndex = $prevRow.prop('rowIndex') - 1;
- self._selectCell(prevRowIndex, self.columns.length - 1, {inc: -1});
- });
+ _moveToNextLine: function (options) {
+ this._moveToSideLine(true, options);
+ },
+ /**
+ * Moves to the previous row in the list
+ *
+ * @private
+ * @params {Object} [options] see @_moveToSideLine
+ */
+ _moveToPreviousLine: function (options) {
+ this._moveToSideLine(false, options);
},
/**
- * Move the cursor on the beginning of the next line, if possible.
- * If we are on the last line (of a group in the grouped case) and the list
+ * Moves the focus to the nearest editable row before or after the current one.
+ * If we arrive at the end of the list (or of a group in the grouped case) and the list
* is editable="bottom", we create a new record, otherwise, we move the
- * cursor to the first line (of the next group in the grouped case).
+ * cursor to the first row (of the next group in the grouped case).
*
* @private
+ * @param {number} next whether to move to the next or previous row
* @param {Object} [options]
* @param {boolean} [options.forceCreate=false] typically set to true when
* navigating with ENTER ; in this case, if the next row is the 'Add a
- * line' row, always create a new record (never skip it, like TAB does
+ * row' one, always create a new record (never skip it, like TAB does
* under some conditions)
*/
- _moveToNextLine: function (options) {
- var self = this;
+ _moveToSideLine: function (next, options) {
options = options || {};
- var recordID = this._getRecordID(this.currentRow);
- var record = this._getRecord(recordID);
-
- this.commitChanges(recordID).then(function () {
- var fieldNames = self.canBeSaved(recordID);
- if (fieldNames.length && (record.isDirty() || options.forceCreate)) {
- // the current row is invalid, we only leave it if it is not dirty
- // (we didn't make any change on this row, which is a new one) and
- // we are navigating with TAB (forceCreate=false)
- return;
+ const recordID = this._getRecordID(this.currentRow);
+ this.commitChanges(recordID).then(() => {
+ const record = this._getRecord(recordID);
+ const multiEdit = this.isInMultipleRecordEdition(recordID);
+ if (!multiEdit) {
+ const fieldNames = this.canBeSaved(recordID);
+ if (fieldNames.length && (record.isDirty() || options.forceCreate)) {
+ // the current row is invalid, we only leave it if it is not dirty
+ // (we didn't make any change on this row, which is a new one) and
+ // we are navigating with TAB (forceCreate=false)
+ return;
+ }
}
-
// compute the index of the next (record) row to select, if any
- var nextRowIndex = null;
- var groupId;
- if (!self.isGrouped) {
+ const side = next ? 'first' : 'last';
+ const borderRowIndex = this._getBorderRow(side).prop('rowIndex') - 1;
+ const cellIndex = next ? 0 : this.allFieldWidgets[recordID].length - 1;
+ const cellOptions = { inc: next ? 1 : -1, force: true };
+ const $currentRow = this._getRow(recordID);
+ const $nextRow = this._getNearestEditableRow($currentRow, next);
+ let nextRowIndex = null;
+ let groupId;
+
+ if (!this.isGrouped) {
// ungrouped case
- if (self.currentRow < self.state.data.length - 1) {
- nextRowIndex = self.currentRow + 1;
+ if ($nextRow.length) {
+ nextRowIndex = $nextRow.prop('rowIndex') - 1;
+ } else if (!this.editable) {
+ nextRowIndex = borderRowIndex;
} else if (!options.forceCreate && !record.isDirty()) {
- self.trigger_up('discard_changes', {
+ this.trigger_up('discard_changes', {
recordID: recordID,
- onSuccess: function () {
- self.trigger_up('activate_next_widget');
- },
+ onSuccess: this.trigger_up.bind(this, 'activate_next_widget', { side: side }),
});
return;
}
} else {
// grouped case
- var $currentRow = self._getRow(recordID);
- var $nextRow = $currentRow.next();
- if ($nextRow.hasClass('o_data_row')) {
- // the next row is a record row (in same group), select it
- nextRowIndex = self.currentRow + 1;
- } else if ($nextRow.hasClass('o_add_record_row') && self.editable === "bottom") {
+ var $directNextRow = $currentRow.next();
+ if (next && this.editable === "bottom" && $directNextRow.hasClass('o_add_record_row')) {
// the next row is the 'Add a line' row (i.e. the current one is the last record
// row of the group)
if (options.forceCreate || record.isDirty()) {
// if we modified the current record, add a row to create a new record
- groupId = $nextRow.data('group-id');
+ groupId = $directNextRow.data('group-id');
} else {
// if we didn't change anything to the current line (e.g. we pressed TAB on
// each cell without modifying/entering any data), we discard that line (if
// it was a new one) and move to the first record of the next group
- nextRowIndex = self._getNextGroupFirstRowIndex($currentRow);
- self.trigger_up('discard_changes', {
+ nextRowIndex = ($nextRow.prop('rowIndex') - 1) || null;
+ this.trigger_up('discard_changes', {
recordID: recordID,
- onSuccess: function () {
+ onSuccess: () => {
if (nextRowIndex !== null) {
if (!record.res_id) {
// the current record was a new one, so we decrement
// nextRowIndex as that row has been removed meanwhile
nextRowIndex--;
}
- self._selectCell(nextRowIndex, 0);
+ this._selectCell(nextRowIndex, cellIndex, cellOptions);
} else {
// we were in the last group, so go back to the top
- self._selectCell(self._getFirstDataRowIndex(), 0, {});
+ this._selectCell(borderRowIndex, cellIndex, cellOptions);
}
},
});
@@ -881,28 +925,26 @@ ListRenderer.include({
}
} else {
// there is no 'Add a line' row (i.e. the create feature is disabled), or the
- // list is editable="top", we focus the first record of the next group if any
- nextRowIndex = self._getNextGroupFirstRowIndex($currentRow);
- if (nextRowIndex === null) {
- // we were on the last group, so we go back to the top of the list
- nextRowIndex = self._getFirstDataRowIndex();
- }
+ // list is editable="top", we focus the first record of the next group if any,
+ // or we go back to the top of the list
+ nextRowIndex = $nextRow.length ?
+ ($nextRow.prop('rowIndex') - 1) :
+ borderRowIndex;
}
}
// if there is a (record) row to select, select it, otherwise, add a new record (in the
// correct group, if the view is grouped)
if (nextRowIndex !== null) {
- self._selectCell(nextRowIndex, 0);
- } else {
- self.unselectRow().then(function () {
- // if for some reason (e.g. create feature is disabled) we can't add a new
- // record, select the first record row
- self.trigger_up('add_record', {
- groupId: groupId,
- onFail: self._selectCell.bind(self, self._getFirstDataRowIndex(), 0, {}),
- });
- });
+ // cellOptions.force = true;
+ this._selectCell(nextRowIndex, cellIndex, cellOptions);
+ } else if (this.editable) {
+ // if for some reason (e.g. create feature is disabled) we can't add a new
+ // record, select the first record row
+ this.unselectRow().then(this.trigger_up.bind(this, 'add_record', {
+ groupId: groupId,
+ onFail: this._selectCell.bind(this, borderRowIndex, cellIndex, cellOptions),
+ }));
}
});
},
@@ -915,29 +957,27 @@ ListRenderer.include({
_processColumns: function () {
const oldColumns = this.columns;
this._super.apply(this, arguments);
- if (this.editable) {
- // check if stored widths still apply
- if (this.columnWidths && oldColumns && oldColumns.length === this.columns.length) {
- for (let i = 0; i < oldColumns.length; i++) {
- if (oldColumns[i] !== this.columns[i]) {
- this.columnWidths = false; // columns changed, so forget stored widths
- break;
- }
+ // check if stored widths still apply
+ if (this.columnWidths && oldColumns && oldColumns.length === this.columns.length) {
+ for (let i = 0; i < oldColumns.length; i++) {
+ if (oldColumns[i] !== this.columns[i]) {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ break;
}
- } else {
- this.columnWidths = false; // columns changed, so forget stored widths
- }
- // if we don't have widths yet, computed them
- if (!this.columnWidths) {
- this.columns.forEach((column) => {
- const width = this._getColumnWidth(column);
- if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px)
- column.attrs.absoluteWidth = width;
- } else { // relative width expressed as a weight (e.g. 1.5)
- column.attrs.relativeWidth = parseFloat(width, 10);
- }
- });
}
+ } else {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ }
+ // if we don't have widths yet, computed them
+ if (!this.columnWidths) {
+ this.columns.forEach((column) => {
+ const width = this._getColumnWidth(column);
+ if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px)
+ column.attrs.absoluteWidth = width;
+ } else { // relative width expressed as a weight (e.g. 1.5)
+ column.attrs.relativeWidth = parseFloat(width, 10);
+ }
+ });
}
},
/**
@@ -1006,6 +1046,17 @@ ListRenderer.include({
return $body;
},
/**
+ * @override
+ * @private
+ */
+ _renderFooter: function () {
+ const $footer = this._super.apply(this, arguments);
+ if (this.addTrashIcon) {
+ $footer.find('tr').append($('<td>'));
+ }
+ return $footer;
+ },
+ /**
* Override to optionally add a th in the header for the remove icon column.
*
* @override
@@ -1014,13 +1065,9 @@ ListRenderer.include({
_renderHeader: function () {
var $thead = this._super.apply(this, arguments);
- if (this.editable && !this.columnWidths) {
+ if (!this.columnWidths) {
if (!this._hasVisibleRecords(this.state)) {
- // we compute the sum of the weights for each columns, excluding
- // those with an absolute width.
- var totalWidth = this.columns.reduce(function (acc, column) {
- return acc + (column.attrs.relativeWidth || 0);
- }, 0);
+ var totalWidth = this._getColumnsTotalWidth($thead);
this.columns.forEach(function (column) {
let width;
if (column.attrs.absoluteWidth) {
@@ -1058,7 +1105,7 @@ ListRenderer.include({
*/
_renderHeaderCell: function () {
const $th = this._super.apply(this, arguments);
- if (this.editable && $th[0].innerHTML.length && this._hasVisibleRecords(this.state)) {
+ if ($th[0].innerHTML.length && this._hasVisibleRecords(this.state)) {
const resizeHandle = document.createElement('span');
resizeHandle.classList = 'o_resize';
resizeHandle.onclick = this._onClickResize.bind(this);
@@ -1133,21 +1180,12 @@ ListRenderer.include({
* @returns {Promise} this promise is resolved immediately
*/
_renderView: function () {
- var self = this;
this.currentRow = null;
- this.allRecordsIds = null;
- return this._super.apply(this, arguments).then(function () {
- var table = self.el.getElementsByTagName('table')[0];
- if (table) { // no table if no content helper displayed
- if (!self._hasVisibleRecords(self.state)) {
- table.classList.add('o_empty_list');
- }
- if (self.editable) {
- table.classList.add('o_editable_list');
- if (self.isInDOM) {
- self._freezeColumnWidths();
- }
- }
+ return this._super.apply(this, arguments).then(() => {
+ const table = this.el.getElementsByTagName('table')[0];
+ if (table) {
+ table.classList.toggle('o_empty_list', !this._hasVisibleRecords(this.state));
+ this._freezeColumnWidths();
}
});
},
@@ -1217,6 +1255,10 @@ ListRenderer.include({
if (rowIndex === this.currentRow) {
return Promise.resolve();
}
+ if (!this.columnWidths) {
+ // we don't want the column widths to change when selecting rows
+ this._storeColumnWidths();
+ }
this._freezeColumnWidths();
var recordId = this._getRecordID(rowIndex);
// To select a row, the currently selected one must be unselected first
@@ -1243,6 +1285,75 @@ ListRenderer.include({
});
});
},
+ /**
+ * Set a maximum width on the largest columns in the list in case the table
+ * is overflowing. The idea is to shrink largest columns first, but to
+ * ensure that they are still the largest at the end (maybe in equal measure
+ * with other columns).
+ *
+ * @private
+ * @returns {integer[]} width (in px) of each column s.t. the table doesn't
+ * overflow
+ */
+ _squeezeTable: function () {
+ const table = this.el.getElementsByTagName('table')[0];
+ const thead = table.getElementsByTagName('thead')[0];
+ const thElements = [...thead.getElementsByTagName('th')];
+ const columnWidths = thElements.map(th => th.offsetWidth);
+ const getWidth = th => columnWidths[thElements.indexOf(th)] || 0;
+ const getTotalWidth = () => thElements.reduce((tot, th, i) => tot + columnWidths[i], 0);
+ const shrinkColumns = (columns, width) => {
+ let thresholdReached = false;
+ columns.forEach(th => {
+ const index = thElements.indexOf(th);
+ let maxWidth = columnWidths[index] - Math.ceil(width / columns.length);
+ if (maxWidth < 92) { // prevent the columns from shrinking under 92px (~ date field)
+ maxWidth = 92;
+ thresholdReached = true;
+ }
+ th.style.maxWidth = `${maxWidth}px`;
+ columnWidths[index] = maxWidth;
+ });
+ return thresholdReached;
+ };
+ // Sort columns, largest first
+ const sortedThs = [...thead.getElementsByTagName('th')]
+ .sort((a, b) => getWidth(b) - getWidth(a));
+ const allowedWidth = table.parentNode.offsetWidth;
+
+ let totalWidth = getTotalWidth();
+ let stop = false;
+ let index = 0;
+ while (totalWidth > allowedWidth && !stop) {
+ // Find the largest columns
+ index++;
+ const largests = sortedThs.slice(0, index);
+ while (getWidth(largests[0]) === getWidth(sortedThs[index])) {
+ largests.push(sortedThs[index]);
+ index++;
+ }
+
+ // Compute the number of px to remove from the largest columns
+ const nextLargest = sortedThs[index]; // largest column when omitting those in largests
+ const totalToRemove = totalWidth - allowedWidth;
+ const canRemove = (getWidth(largests[0]) - getWidth(nextLargest)) * largests.length;
+
+ // Shrink the largests columns
+ stop = shrinkColumns(largests, Math.min(totalToRemove, canRemove));
+
+ totalWidth = getTotalWidth();
+ }
+
+ return columnWidths;
+ },
+ /**
+ * @private
+ */
+ _storeColumnWidths: function () {
+ this.columnWidths = this.$('thead th').toArray().map(function (th) {
+ return $(th).outerWidth();
+ });
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -1302,12 +1413,12 @@ ListRenderer.include({
_onCellClick: function (event) {
// The special_click property explicitely allow events to bubble all
// the way up to bootstrap's level rather than being stopped earlier.
- if (!this.editable || $(event.target).prop('special_click')) {
- return;
- }
var $td = $(event.currentTarget);
var $tr = $td.parent();
var rowIndex = $tr.prop('rowIndex') - 1;
+ if (!this._isRecordEditable($tr.data('id')) || $(event.target).prop('special_click')) {
+ return;
+ }
var fieldIndex = Math.max($tr.find('.o_field_cell').index($td), 0);
this._selectCell(rowIndex, fieldIndex, {event: event});
},
@@ -1344,15 +1455,17 @@ ListRenderer.include({
* @override
*/
_onKeyDown: function (ev) {
- var $target = $(ev.currentTarget);
- var $tr = $target.closest('tr');
+ const $target = $(ev.currentTarget);
+ const $tr = $target.closest('tr');
+ const recordEditable = this._isRecordEditable($tr.data('id'));
- if (this.editable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) {
+ if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) {
// enter on a textarea for example, let it bubble
return;
}
- if (this.editable && ev.keyCode === $.ui.keyCode.ENTER && !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) {
+ if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER &&
+ !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) {
ev.stopPropagation();
ev.preventDefault();
if ($target.closest('td').hasClass('o_group_field_row_add')) {
@@ -1417,7 +1530,13 @@ ListRenderer.include({
}
break;
case 'next_line':
- this._moveToNextLine({forceCreate: true});
+ // If the list is readonly and the current is the only record editable, we unselect the line
+ if (!this.editable && this.selection.length === 1 &&
+ this._getRecordID(this.currentRow) === ev.target.dataPointID) {
+ this.unselectRow();
+ } else {
+ this._moveToNextLine({ forceCreate: true });
+ }
break;
case 'cancel':
// stop the original event (typically an ESCAPE keydown), to
@@ -1479,9 +1598,14 @@ ListRenderer.include({
* @override
* @private
*/
- _onRowClicked: function () {
- if (!this.editable) {
- this._super.apply(this, arguments);
+ _onRowClicked: function (ev) {
+ if (!this._isRecordEditable(ev.currentTarget.dataset.id)) {
+ // If there is an edited record, tries to save it and do not open the clicked record
+ if (this.getEditableRecordID()) {
+ this.unselectRow();
+ } else {
+ this._super.apply(this, arguments);
+ }
}
},
/**
@@ -1513,8 +1637,6 @@ ListRenderer.include({
const table = this.el.getElementsByTagName('table')[0];
const th = ev.target.closest('th');
- // Freeze the whole table width
- this._freezeColumnWidths();
table.style.width = `${table.offsetWidth}px`;
const thPosition = [...th.parentNode.children].indexOf(th);
const resizingColumnElements = [...table.getElementsByTagName('tr')]
@@ -1607,6 +1729,11 @@ ListRenderer.include({
* @param {MouseEvent} event
*/
_onWindowClicked: function (event) {
+ // ignore clicks on readonly lists with no selected rows
+ if (!this.isEditable()) {
+ return;
+ }
+
// ignore clicks if this renderer is not in the dom.
if (!document.contains(this.el)) {
return;
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 65526346c8e..9ba0b473a5a 100644
--- a/addons/web/static/src/js/views/list/list_model.js
+++ b/addons/web/static/src/js/views/list/list_model.js
@@ -37,15 +37,16 @@ odoo.define('web.ListModel', function (require) {
* For a list of records, performs a write with all changes and fetches
* all data.
*
+ * @param {string} listDatapointId id of the parent list
* @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, fieldName) {
+ saveRecords: function (listDatapointId, referenceRecordId, recordIds, fieldName) {
var self = this;
var referenceRecord = this.localData[referenceRecordId];
- var list = this.localData[referenceRecord.parentID];
+ var list = this.localData[listDatapointId];
// 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
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 5daa33125a7..53a60d011e6 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -39,10 +39,10 @@ var ListRenderer = BasicRenderer.extend({
"click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn",
"click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown",
'click tbody tr': '_onRowClicked',
- 'click tbody .o_list_record_selector': '_onSelectRecord',
+ 'change tbody .o_list_record_selector': '_onSelectRecord',
'click thead th.o_column_sortable': '_onSortColumn',
'click .o_group_header': '_onToggleGroup',
- 'click thead .o_list_record_selector input': '_onToggleSelection',
+ 'change thead .o_list_record_selector input': '_onToggleSelection',
'keypress thead tr td': '_onKeyPress',
'keydown td': '_onKeyDown',
'keydown th': '_onKeyDown',
@@ -287,7 +287,14 @@ var ListRenderer = BasicRenderer.extend({
if (c.attrs.name in columnInvisibleFields) {
reject = columnInvisibleFields[c.attrs.name];
}
-
+ if (c.attrs.class) {
+ if (c.attrs.class.match(/\boe_edit_only\b/)) {
+ c.attrs.editOnly = true;
+ }
+ if (c.attrs.class.match(/\boe_read_only\b/)) {
+ c.attrs.readOnly = true;
+ }
+ }
if (!reject && c.attrs.widget === 'handle') {
self.handleField = c.attrs.name;
if (self.isGrouped) {
@@ -331,6 +338,12 @@ var ListRenderer = BasicRenderer.extend({
if (config.isDebug()) {
$cell.addClass(column.attrs.name);
}
+ if (column.attrs.editOnly) {
+ $cell.addClass('oe_edit_only');
+ }
+ if (column.attrs.readOnly) {
+ $cell.addClass('oe_read_only');
+ }
if (column.attrs.name in aggregateValues) {
var field = self.state.fields[column.attrs.name];
var value = aggregateValues[column.attrs.name].value;
@@ -394,6 +407,12 @@ var ListRenderer = BasicRenderer.extend({
tdClassName += (' o_' + node.attrs.widget + '_cell');
}
}
+ if (node.attrs.editOnly) {
+ tdClassName += ' oe_edit_only';
+ }
+ if (node.attrs.readOnly) {
+ tdClassName += ' oe_read_only';
+ }
var $td = $('<td>', { class: tdClassName, tabindex: -1 });
// We register modifiers on the <td> element so that it gets the correct
@@ -748,13 +767,11 @@ var ListRenderer = BasicRenderer.extend({
if (name) {
$th.attr('data-name', name);
}
- if (node.attrs.class) {
- if (node.attrs.class.indexOf('oe_edit_only') !== -1) {
- $th.addClass('oe_edit_only');
- }
- if (node.attrs.class.indexOf('oe_read_only') !== -1) {
- $th.addClass('oe_read_only');
- }
+ if (node.attrs.editOnly) {
+ $th.addClass('oe_edit_only');
+ }
+ if (node.attrs.readOnly) {
+ $th.addClass('oe_read_only');
}
if (!field) {
return $th;
@@ -1179,7 +1196,7 @@ var ListRenderer = BasicRenderer.extend({
_onRowClicked: function (ev) {
// The special_click property explicitely allow events to bubble all
// the way up to bootstrap's level rather than being stopped earlier.
- if (!$(ev.target).prop('special_click')) {
+ if (!ev.target.closest('.o_list_record_selector') && !$(ev.target).prop('special_click')) {
var id = $(ev.currentTarget).data('id');
if (id) {
this.trigger_up('open_record', { id: id, target: ev.target });
diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js
index baba4d4a35f..bb6dfed8116 100644
--- a/addons/web/static/src/js/views/list/list_view.js
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -41,7 +41,6 @@ var ListView = BasicView.extend({
this._super.apply(this, arguments);
var selectedRecords = []; // there is no selected records by default
- var mode = this.arch.attrs.editable && !params.readonly ? "edit" : "readonly";
var pyevalContext = py.dict.fromJSON(_.pick(params.context, function(value, key, object) {return !_.isUndefined(value)}) || {});
var expandGroups = !!JSON.parse(pyUtils.py_eval(this.arch.attrs.expand || "0", {'context': pyevalContext}));
@@ -52,23 +51,27 @@ var ListView = BasicView.extend({
}
});
- this.controllerParams.editable = (!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) ?
- this.arch.attrs.editable : false;
+ let editable = false;
+ if ((!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) && !params.readonly) {
+ editable = this.arch.attrs.editable;
+ }
+
+ this.controllerParams.activeActions.export_xlsx = this.arch.attrs.export_xlsx ? !!JSON.parse(this.arch.attrs.export_xlsx): true;
+ this.controllerParams.editable = editable;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
- this.controllerParams.mode = mode;
+ this.controllerParams.mode = editable ? 'edit' : 'readonly';
this.controllerParams.selectedRecords = selectedRecords;
this.rendererParams.arch = this.arch;
this.rendererParams.groupbys = this.groupbys;
this.rendererParams.hasSelectors =
'hasSelectors' in params ? params.hasSelectors : true;
- this.rendererParams.editable =
- (!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) && !params.readonly ?
- this.arch.attrs.editable : false;
+ this.rendererParams.editable = editable;
this.rendererParams.selectedRecords = selectedRecords;
this.rendererParams.addCreateLine = false;
- this.rendererParams.addCreateLineInGroups = this.rendererParams.editable && this.controllerParams.activeActions.create;
+ this.rendererParams.addCreateLineInGroups = editable && this.controllerParams.activeActions.create;
+ this.rendererParams.isMultiEditable = this.arch.attrs.multi_edit && !!JSON.parse(this.arch.attrs.multi_edit);
this.modelParams.groupbys = this.groupbys;
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
index 070b1927c5f..97ec75e6b47 100644
--- a/addons/web/static/src/js/widgets/data_export.js
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -34,11 +34,11 @@ var DataExport = Dialog.extend({
* @param {Object} record
* @param {string[]} defaultExportFields
*/
- init: function (parent, record, defaultExportFields) {
+ init: function (parent, record, defaultExportFields, groupedBy, activeDomain, idsToExport) {
var options = {
title: _t("Export Data"),
buttons: [
- {text: _t("Export"), click: this._exportData, classes: 'btn-primary'},
+ {text: _t("Export"), click: this._onExportData, classes: 'btn-primary'},
{text: _t("Close"), close: true},
],
};
@@ -46,9 +46,13 @@ var DataExport = Dialog.extend({
this.records = {};
this.record = record;
this.defaultExportFields = defaultExportFields;
+ this.groupby = groupedBy;
this.exports = new data.DataSetSearch(this, 'ir.exports', this.record.getContext());
this.rowIndex = 0;
this.rowIndexLevel = 0;
+ this.isCompatibleMode = false;
+ this.domain = activeDomain || this.record.domain;
+ this.idsToExport = activeDomain ? false: idsToExport;
},
/**
* @override
@@ -73,16 +77,6 @@ var DataExport = Dialog.extend({
});
}));
- proms.push(this.getParent().getActiveDomain().then(function (domain) {
- if (domain === undefined) {
- self.idsToExport = self.getParent().getSelectedIds();
- self.domain = self.record.domain;
- } else {
- self.idsToExport = false;
- self.domain = domain;
- }
- }));
-
proms.push(this._showExportsList());
// Bind sortable events after Dialog is open
@@ -117,6 +111,17 @@ var DataExport = Dialog.extend({
}
},
+ /**
+ * Export all data with default values (fields, domain)
+ */
+ export() {
+ let exportedFields = this.defaultExportFields.map(field => ({
+ name: field,
+ label: this.record.fields[field].string,
+ }));
+ this._exportData(exportedFields, 'xlsx', false);
+ },
+
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -146,13 +151,7 @@ var DataExport = Dialog.extend({
*
* @private
*/
- _exportData: function () {
- var exportedFields = this.$('.o_export_field').map(function () {
- return {
- name: $(this).data('field_id'),
- label: this.textContent
- };
- }).get();
+ _exportData(exportedFields, exportFormat, idsToExport) {
if (_.isEmpty(exportedFields)) {
Dialog.alert(this, _t("Please select fields to export..."));
@@ -162,8 +161,6 @@ var DataExport = Dialog.extend({
exportedFields.unshift({ name: 'id', label: _t('External ID') });
}
- var exportFormat = this.$exportFormatInputs.filter(':checked').val();
-
framework.blockUI();
this.getSession().get_file({
url: '/web/export/' + exportFormat,
@@ -171,8 +168,9 @@ var DataExport = Dialog.extend({
data: JSON.stringify({
model: this.record.model,
fields: exportedFields,
- ids: this.idsToExport,
+ ids: idsToExport,
domain: this.domain,
+ groupby: this.groupby,
context: pyUtils.eval('contexts', [this.record.getContext()]),
import_compat: this.isCompatibleMode,
})
@@ -546,6 +544,20 @@ var DataExport = Dialog.extend({
}
},
/**
+ * Submit the user data and export the file
+ *
+ * @private
+ */
+ _onExportData() {
+ let exportedFields = this.$('.o_export_field').map((i, field) => ({
+ name: $(field).data('field_id'),
+ label: field.textContent,
+ }
+ )).get();
+ let exportFormat = this.$exportFormatInputs.filter(':checked').val();
+ this._exportData(exportedFields, exportFormat, this.idsToExport);
+ },
+ /**
* Add a field to export field list on double click
*
* @private
diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js
index 3f528fe7c37..95908433fec 100644
--- a/addons/web/static/src/js/widgets/date_picker.js
+++ b/addons/web/static/src/js/widgets/date_picker.js
@@ -42,7 +42,7 @@ var DateWidget = Widget.extend({
next: 'fa fa-chevron-right',
today: 'fa fa-calendar-check-o',
clear: 'fa fa-delete',
- close: 'fa fa-times'
+ close: 'fa fa-check primary',
},
calendarWeeks: true,
buttons: {
@@ -91,6 +91,13 @@ var DateWidget = Widget.extend({
* set datetime value
*/
changeDatetime: function () {
+ if (this.__libInput > 0) {
+ if (this.options.warn_future) {
+ this._warnFuture(this.getValue());
+ }
+ this.trigger("datetime_changed");
+ return;
+ }
var oldValue = this.getValue();
if (this.isValid()) {
this._setValueFromUi();
@@ -253,6 +260,7 @@ var DateWidget = Widget.extend({
*/
_onDateTimePickerHide: function () {
this.__isOpen = false;
+ this.changeDatetime();
if (this._onScroll) {
window.removeEventListener('scroll', this._onScroll, true);
}
diff --git a/addons/web/static/src/js/widgets/translation_dialog.js b/addons/web/static/src/js/widgets/translation_dialog.js
index 39d841a48d0..426ef68cb35 100644
--- a/addons/web/static/src/js/widgets/translation_dialog.js
+++ b/addons/web/static/src/js/widgets/translation_dialog.js
@@ -23,6 +23,7 @@ odoo.define('web.TranslationDialog', function (require) {
* @param {string} [options.dataPointID] the data point id of the record for which we do the translations
* @param {boolean} [options.isComingFromTranslationAlert] the initiator of the dialog, might be a link on a field or the translation alert on top of the form
* @param {boolean} [options.isText] is the field a text field (multiline) or char (single line)
+ * @param {boolean} [options.showSrc] is the source of the translation should be rendered (for partial translations, i.e. XML content)
*
*/
init: function (parent, options) {
@@ -37,6 +38,7 @@ odoo.define('web.TranslationDialog', function (require) {
this.isComingFromTranslationAlert = options.isComingFromTranslationAlert;
this.currentInterfaceLanguage = session.user_context.lang;
this.isText = options.isText;
+ this.showSrc = options.showSrc;
this._super(parent, _.extend({
size: 'large',
@@ -66,15 +68,18 @@ odoo.define('web.TranslationDialog', function (require) {
id: term.id,
lang: term.lang,
langName: relatedLanguage[1],
+ source: term.src,
// we set the translation value coming from the database, except for the language
// the user is currently utilizing. Then we set the translation value coming
// from the value of the field in the form
value: (term.lang === this.currentInterfaceLanguage &&
+ !this.showSrc &&
!this.isComingFromTranslationAlert) ?
this.userLanguageValue : term.value || ''
};
});
- this.data.sort((left, right) => left.langName < right.langName ? -1 : 1);
+ this.data.sort((left, right) =>
+ (left.langName < right.langName || (left.langName === right.langName && left.source < right.source)) ? -1 : 1);
});
},
@@ -89,7 +94,7 @@ odoo.define('web.TranslationDialog', function (require) {
return this._rpc({
model: 'ir.translation',
method: 'search_read',
- fields: ['lang', 'value'],
+ fields: ['lang', 'src', 'value'],
domain: this.domain,
});
},
@@ -132,10 +137,12 @@ odoo.define('web.TranslationDialog', function (require) {
if (initialValue.value !== t.value) {
updatedTerm[t.dataset.id] = t.value;
- if (initialValue.lang === this.currentInterfaceLanguage) {
+ if (initialValue.lang === this.currentInterfaceLanguage && !this.showSrc) {
// when the user has changed the term for the language he is
// using in the interface, this change should be reflected
// in the form view
+ // partial translations being handled server side are
+ // also ignored
var changes = {};
changes[this.fieldName] = updatedTerm[initialValue.id];
updateFormViewField = {
diff --git a/addons/web/static/src/scss/bootstrap_overridden.scss b/addons/web/static/src/scss/bootstrap_overridden.scss
index dc1c2bab30c..2bfbc0ea266 100644
--- a/addons/web/static/src/scss/bootstrap_overridden.scss
+++ b/addons/web/static/src/scss/bootstrap_overridden.scss
@@ -61,7 +61,7 @@ $component-active-bg: $gray-200 !default;
//
// Font, line-height, and color for body text, headings, and more.
-$font-family-sans-serif: "Noto", "Lucida Grande", Helvetica, Verdana, Arial, sans-serif !default;
+$font-family-sans-serif: o-add-unicode-support-font(("Lucida Grande", Helvetica, Verdana, Arial, sans-serif), 1) !default;
$font-size-base: $o-font-size-base !default;
$line-height-base: $o-line-height-base !default;
diff --git a/addons/web/static/src/scss/bootstrap_overridden_report.scss b/addons/web/static/src/scss/bootstrap_overridden_report.scss
index a6e1cd23b32..33af8de1637 100644
--- a/addons/web/static/src/scss/bootstrap_overridden_report.scss
+++ b/addons/web/static/src/scss/bootstrap_overridden_report.scss
@@ -1,2 +1,2 @@
-// remove Noto Color Emoji
-$font-family-sans-serif: "Noto", "Lucida Grande", Helvetica, Verdana, Arial, sans-serif;
+// remove Emoji fonts
+$font-family-sans-serif: o-add-unicode-support-font(("Lucida Grande", Helvetica, Verdana, Arial, sans-serif), 1);
diff --git a/addons/web/static/src/scss/datepicker.scss b/addons/web/static/src/scss/datepicker.scss
index 7232b8c97a8..efc09b2d790 100644
--- a/addons/web/static/src/scss/datepicker.scss
+++ b/addons/web/static/src/scss/datepicker.scss
@@ -84,6 +84,20 @@ div.dropdown-menu.bootstrap-datetimepicker-widget{
}
}
+.picker-switch {
+ span.fa {
+ margin: 0;
+ @include transition($btn-transition);
+ &.primary {
+ background-color: $o-brand-primary;
+ color: white;
+ &:hover {
+ background-color: darken($o-brand-primary, 10%);
+ }
+ }
+ }
+}
+
// This is rtl language specific fix
// it will fix the user agent stylesheet padding
// and transform the prev and next icons
diff --git a/addons/web/static/src/scss/fonts.scss b/addons/web/static/src/scss/fonts.scss
index d1387316e68..26996d89c4c 100644
--- a/addons/web/static/src/scss/fonts.scss
+++ b/addons/web/static/src/scss/fonts.scss
@@ -6,7 +6,7 @@ $lato-font-path: '../fonts/lato';
@mixin lato-font($type, $weight, $style) {
// Cyrillic: U+0400-04FF, U+0500-052F
@font-face {
- font-family: 'Noto';
+ font-family: 'Odoo Unicode Support Noto';
src: url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.woff2') format('woff2'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.woff') format('woff'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.ttf') format('truetype');
@@ -16,7 +16,7 @@ $lato-font-path: '../fonts/lato';
}
// Hebrew: U+0590-05FF, U+FB1D-FB4F
@font-face {
- font-family: 'Noto';
+ font-family: 'Odoo Unicode Support Noto';
src: url('https://fonts.odoocdn.com/fonts/noto/NotoSansHebrew-#{$type}.woff2') format('woff2'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSansHebrew-#{$type}.woff') format('woff'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSansHebrew-#{$type}.ttf') format('truetype');
@@ -26,7 +26,7 @@ $lato-font-path: '../fonts/lato';
}
// Arabic: U+0600-06FF, U+0750-077F
@font-face {
- font-family: 'Noto';
+ font-family: 'Odoo Unicode Support Noto';
src: url('https://fonts.odoocdn.com/fonts/noto/NotoSansArabic-#{$type}.woff2') format('woff2'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSansArabic-#{$type}.woff') format('woff'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSansArabic-#{$type}.ttf') format('truetype');
@@ -34,6 +34,7 @@ $lato-font-path: '../fonts/lato';
font-style: $style;
unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF;
}
+
@font-face {
font-family: 'Lato';
src: url('#{$lato-font-path}/Lato-#{$type}-webfont.eot');
@@ -44,7 +45,6 @@ $lato-font-path: '../fonts/lato';
font-weight: $weight;
font-style: $style;
}
-
@font-face {
font-family: 'Lato-#{$type}';
src: url('#{$lato-font-path}/Lato-#{$type}-webfont.eot');
diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss
index a46d4632c24..03a3dbb26e3 100644
--- a/addons/web/static/src/scss/kanban_view.scss
+++ b/addons/web/static/src/scss/kanban_view.scss
@@ -6,7 +6,6 @@
@include media-breakpoint-down(sm) {
padding: 0px!important;
- overflow-x: auto;
}
.o_kanban_record, .o_kanban_quick_create {
diff --git a/addons/web/static/src/scss/kanban_view_mobile.scss b/addons/web/static/src/scss/kanban_view_mobile.scss
index e464fd88b8a..ccb6bc82a98 100644
--- a/addons/web/static/src/scss/kanban_view_mobile.scss
+++ b/addons/web/static/src/scss/kanban_view_mobile.scss
@@ -32,7 +32,6 @@
// [class] to get same specificity as elsewhere (kanban_view.less)
&[class] .o_kanban_group:not(.o_column_folded) {
@include o-position-absolute($top: $o-kanban-mobile-tabs-height, $left: 0, $bottom: 0);
- overflow-x: scroll;
width: 100%;
padding: 0;
margin-left: 0; // override the margin-left: -1px of the desktop mode
diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss
index 0e62f071d42..c9562df798b 100644
--- a/addons/web/static/src/scss/list_view.scss
+++ b/addons/web/static/src/scss/list_view.scss
@@ -34,13 +34,13 @@
&.o_list_number {
text-align: right;
}
- &.o_list_char,
- &.o_list_text,
- &.o_list_many2one {
- white-space: pre-wrap;
- }
&.o_list_text {
word-break: break-word;
+ white-space: pre-wrap;
+ }
+ &.o_list_char.o_color_cell .o_field_color {
+ width: 17px;
+ height: 17px;
}
}
@@ -52,6 +52,9 @@
border-top: 2px solid #cacaca;
border-bottom: 1px solid #cacaca;
+ > tr > td {
+ @include o-text-overflow(table-cell);
+ }
.o_list_number {
text-align: right;
}
@@ -177,101 +180,103 @@
display: table-cell!important;
}
- //--------------------------------------------------------------------------
- // Edition
- //--------------------------------------------------------------------------
+ &.o_resizing tr > :not(.o_column_resizing) {
+ opacity: 0.5;
+ }
- &.o_editable_list {
- &.o_resizing tr > :not(.o_column_resizing) {
- opacity: 0.5;
- }
+ &.o_empty_list {
+ table-layout: fixed;
+ }
- &.o_empty_list {
- table-layout: fixed;
- }
+ th {
+ position: relative;
+ }
- th {
- position: relative;
- }
+ span.o_resize {
+ bottom: 0;
+ cursor: col-resize;
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 10px;
+ z-index: 1; // must be over the sort caret (::after pseudo-element)
+ }
- span.o_resize {
- bottom: 0;
- cursor: col-resize;
- position: absolute;
- right: 0;
- top: 0;
- width: 10px;
- z-index: 1; // must be over the sort caret (::after pseudo-element)
- }
+ .o_list_record_remove_header {
+ width: 32px;
+ }
- .o_list_record_remove_header {
- width: 32px;
- }
- &>tbody>tr>td.o_list_char,
- &>tbody>tr>td.o_list_many2one {
- white-space: nowrap;
- }
- // checkboxes in editable lists should be clickable and activate the row
- .o_data_row:not(.o_selected_row) .o_data_cell {
+ // checkboxes in editable lists should be clickable and activate the row
+ .o_data_row:not(.o_selected_row) .o_data_cell {
&.o_toggle_button_cell:not(.o_readonly_modifier) button, // registry: toggle_button
&.o_boolean_favorite_cell:not(.o_readonly_modifier) .o_favorite, // registry: boolean_favorite
- .custom-checkbox:not(.o_readonly_modifier) {
- pointer-events: none;
+ .custom-checkbox:not(.o_readonly_modifier) {
+ pointer-events: none;
+ }
+ }
+
+ .o_data_row:not(.o_selected_row) {
+ .o_list_many2one,
+ .o_list_char,
+ .o_list_number {
+ white-space: nowrap;
+ .o_field_widget:not(.o_row_handle) {
+ display: inline;
}
}
+ }
- .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier):not(.o_invisible_modifier) {
- position: relative; // for o_field_translate
- background-color: white;
- .o_input {
- border: none;
+ .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier):not(.o_invisible_modifier) {
+ position: relative; // for o_field_translate
+ background-color: white;
+ .o_input {
+ border: none;
+ padding: 0;
+ }
+ > .o_field_text {
+ vertical-align: top;
+ }
+ > .o_field_widget {
+ width: 100%;
+ > .o_external_button {
padding: 0;
+ border: none;
+ background-color: inherit;
+ margin-left: 5px;
+ font-size: 12px;
}
- > .o_field_text {
- vertical-align: top;
- }
- > .o_field_widget {
- width: 100%;
- > .o_external_button {
- padding: 0;
- border: none;
- background-color: inherit;
- margin-left: 5px;
- font-size: 12px;
- }
- }
- > .o_field_monetary input {
- width: 0; // override width: 100px and let flex makes this input grow
- margin: 0 4px;
- }
- > .o_field_many2manytags > .o_field_many2one {
- // reduce basis to prevent many2one input from going to the next line to often
- flex-basis: 40px;
- }
- .o_input_dropdown, .o_datepicker {
- > .o_input {
- padding-right: 15px; // to prevent caret overlapping
- }
- > .o_dropdown_button, .o_datepicker_button {
- margin-right: 5px;
- @include o-position-absolute(0, 0);
- }
+ }
+ > .o_field_monetary input {
+ width: 0; // override width: 100px and let flex makes this input grow
+ margin: 0 4px;
+ }
+ > .o_field_many2manytags > .o_field_many2one {
+ // reduce basis to prevent many2one input from going to the next line to often
+ flex-basis: 40px;
+ }
+ .o_input_dropdown, .o_datepicker {
+ > .o_input {
+ padding-right: 15px; // to prevent caret overlapping
}
- > input.o_field_translate, textarea.o_field_translate {
- padding-right: 25px;
- + span.o_field_translate {
- margin-left: -35px;
- padding: 0px 1px;
- text-align: right;
- width: 35px;
- vertical-align: top;
- font-size: 12px;
- }
+ > .o_dropdown_button, .o_datepicker_button {
+ margin-right: 5px;
+ @include o-position-absolute(0, 0);
}
- > .o_row_handle {
- visibility: hidden; // hide sequence when editing
+ }
+ > input.o_field_translate, textarea.o_field_translate {
+ padding-right: 25px;
+ + span.o_field_translate {
+ margin-left: -35px;
+ padding: 0px 1px;
+ text-align: right;
+ width: 35px;
+ vertical-align: top;
+ font-size: 12px;
}
}
+ > .o_row_handle {
+ visibility: hidden; // hide sequence when editing
+ }
}
.o_field_x2many_list_row_add, .o_group_field_row_add {
@@ -314,6 +319,11 @@
}
}
+.o_view_controller > .o_content > .o_list_view > .table-responsive {
+ // in main list views, let .o_content scroll
+ overflow: visible;
+}
+
// Buttons in ControlPanel
.o_list_buttons {
.o_list_button_save, .o_list_button_discard {
diff --git a/addons/web/static/src/scss/list_view_extra.scss b/addons/web/static/src/scss/list_view_extra.scss
index 9a16fb69347..ef51cfd65e0 100644
--- a/addons/web/static/src/scss/list_view_extra.scss
+++ b/addons/web/static/src/scss/list_view_extra.scss
@@ -12,7 +12,7 @@
}
}
- .o_list_table.o_editable_list .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier) {
+ .o_list_table .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier):not(.o_invisible_modifier) {
padding: .15rem;
.o_input {
border: none;
@@ -32,5 +32,11 @@
&.o_invalid_cell .o_input {
box-shadow: 0 0 0 1px theme-color('danger');
}
+ &.o_list_button {
+ padding: .25rem;
+ }
+ &.o_color_cell > .o_field_widget {
+ padding: .15rem;
+ }
}
}
diff --git a/addons/web/static/src/scss/modal.scss b/addons/web/static/src/scss/modal.scss
index e7243042d35..3463d9e20ac 100644
--- a/addons/web/static/src/scss/modal.scss
+++ b/addons/web/static/src/scss/modal.scss
@@ -27,6 +27,17 @@
.o_view_nocontent {
position: unset;
}
+
+ .o_modal_changes td {
+ &:first-child {
+ padding-right: 10px;
+ vertical-align: top;
+ white-space: nowrap;
+ }
+ &:not(:first-child) {
+ width: 100%;
+ }
+ }
}
.modal-footer {
diff --git a/addons/web/static/src/scss/search_panel.scss b/addons/web/static/src/scss/search_panel.scss
index 0c7b2266a25..0f45a14ac5a 100644
--- a/addons/web/static/src/scss/search_panel.scss
+++ b/addons/web/static/src/scss/search_panel.scss
@@ -14,7 +14,7 @@ $o-searchpanel-filter-default-color: #D59244;
.o_renderer_with_searchpanel {
flex: 1 1 100%;
overflow: auto; // make the renderer and search panel scroll individually
- max-height: 100%;
+ height: 100%;
}
.o_search_panel {
flex: 0 0 220px;
diff --git a/addons/web/static/src/scss/ui.scss b/addons/web/static/src/scss/ui.scss
index 5a46c9c01a8..3a2551b2f96 100644
--- a/addons/web/static/src/scss/ui.scss
+++ b/addons/web/static/src/scss/ui.scss
@@ -8,6 +8,7 @@
.ui-autocomplete {
z-index: $zindex-modal + 1;
+ max-width: 600px;
}
[aria-hidden="true"], [aria-hidden="1"] {
diff --git a/addons/web/static/src/scss/utils.scss b/addons/web/static/src/scss/utils.scss
index 9c3187d01bc..77d9749111f 100644
--- a/addons/web/static/src/scss/utils.scss
+++ b/addons/web/static/src/scss/utils.scss
@@ -173,6 +173,29 @@
}
}
+// Odoo defines a limited Noto font-family for a small variety of unicode
+// characters that are not necessary defined in the user system or even defined
+// but not properly readable. This function allows to add this font family in a
+// given font list.
+//
+// @param {list} $font - a list of font names ending with the generic one.
+// @param {integer} [$index] - the position where to add the support font, if
+// not given, it will be placed before the generic one.
+@function o-add-unicode-support-font($font, $index: false) {
+ @if $index == false {
+ $index: length($font);
+ }
+
+ $-with-support-font: ();
+ @for $i from 1 through length($font) {
+ @if $i == $index {
+ $-with-support-font: append($-with-support-font, 'Odoo Unicode Support Noto', $separator: comma);
+ }
+ $-with-support-font: append($-with-support-font, nth($font, $i), $separator: comma);
+ }
+
+ @return $-with-support-font;
+}
// Mixin which allows to extend the BS4 bg-variant mixin
@mixin o-bg-color-extension($color, $text-color, $with-muted) {}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index f52818d5ed6..b76e86c036d 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -275,12 +275,40 @@
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 t-if="widget.is_action_enabled('export_xlsx')">
+ <button type="button" class="btn btn-secondary fa fa-download o_list_export_xlsx" title="Export All"/>
</t>
</div>
</t>
+<t t-name="ListView.confirmModal">
+ <main role="alert">
+ <p>
+ <t t-if="changes.nbRecords != changes.nbValidRecords">
+ Among the <t t-raw="changes.nbRecords"/> selected records,
+ <t t-raw="changes.nbValidRecords"/> are valid for this update.<br/>
+ </t>
+ Are you sure you want to perform the following update on those <t t-raw="changes.nbValidRecords"/> records ?
+ </p>
+ <div class="table-responsive">
+ <table class="o_modal_changes">
+ <tbody>
+ <tr>
+ <td>Field:</td>
+ <td><t t-raw="changes.fieldLabel"/></td>
+ </tr>
+ <tr>
+ <td>Update to:</td>
+ <td>
+ <div class="o_changes_widget"/>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </main>
+</t>
+
<t t-name="FormView.buttons">
<div class="o_form_buttons_view" role="toolbar" aria-label="Main actions">
<button t-if="widget.is_action_enabled('edit')" type="button"
@@ -1032,8 +1060,8 @@
<t t-if="widget.items.length !== 0">
<t t-set="group" t-value="0"/>
<t t-foreach="widget.items" t-as="item">
- <div t-if="group !== item.groupNumber and !item.invisible" role="separator" class="dropdown-divider" data-removable="1"/>
- <div t-attf-class="o_menu_item #{item.invisible ? 'd-none' : ''}"
+ <div t-if="group !== item.groupNumber" role="separator" class="dropdown-divider" data-removable="1"/>
+ <div t-attf-class="o_menu_item"
t-att-data-id="item.id">
<a role="menuitem" href="#" t-attf-class="dropdown-item#{item.isActive ? ' selected ' : ''}">
<t t-esc="item.description"/>
@@ -1060,7 +1088,7 @@
</t>
<div t-name="web.DropdownMenu" t-att-class="widget.dropdownStyle.el.class" t-att="widget.dropdownStyle.el.attrs">
- <button t-att-class="widget.dropdownStyle.mainButton.class" data-toggle="dropdown" aria-expanded="false" tabindex="-1">
+ <button t-att-class="widget.dropdownStyle.mainButton.class" data-toggle="dropdown" aria-expanded="false" tabindex="-1" data-boundary="viewport">
<span t-att-class="widget.dropdownIcon"/> <t t-esc="widget.dropdownTitle"/> <span t-if="widget.dropdownSymbol" t-att-class="widget.dropdownSymbol"/>
</button>
<div class="dropdown-menu o_dropdown_menu" role="menu">
@@ -1079,7 +1107,7 @@
</select>
</div>
<div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu">
- <button type="button" class="btn btn-primary o_apply_group o_add_group" tabindex="-1">Apply</button>
+ <button type="button" class="btn btn-primary o_apply_group o_add_group">Apply</button>
</div>
</t>
@@ -1540,7 +1568,7 @@
<t t-name="FieldColor">
<div>
- <button class="o_field_color"></button>
+ <button class="o_field_color" t-attf-tabindex="#{widget.mode === 'edit' ? 0 : -1}"></button>
</div>
</t>
diff --git a/addons/web/static/src/xml/translation_dialog.xml b/addons/web/static/src/xml/translation_dialog.xml
index 32e2a036e45..7ee3e2f4a8b 100644
--- a/addons/web/static/src/xml/translation_dialog.xml
+++ b/addons/web/static/src/xml/translation_dialog.xml
@@ -2,10 +2,13 @@
<templates id="template" xml:space="preserve">
<div t-name="TranslationDialog" class="o_translation_dialog">
<div class="row" t-foreach="widget.data" t-as="term">
- <div t-att-class="widget.currentInterfaceLanguage == term.lang ? 'col-12 col-lg-3 o_language_current' : 'col-12 col-lg-3'">
+ <div t-attf-class="col-12 #{widget.showSrc ? 'col-lg-2' : 'col-lg-3'} #{widget.currentInterfaceLanguage == term.lang ? 'o_language_current' : ''}">
<t t-esc="term.langName"></t>
</div>
- <div class="col-12 col-lg-9 translation">
+ <div t-if="widget.showSrc" class="col-12 col-lg-3 source">
+ <t t-esc="term.source"></t>
+ </div>
+ <div t-attf-class="col-12 #{widget.showSrc ? 'col-lg-7' : 'col-lg-9'} translation">
<input t-if="!widget.isText" type="text"
class="o_field_char o_input"
t-att-value="term.value"
@@ -14,7 +17,7 @@
<textarea t-if="widget.isText"
t-esc="term.value"
t-att-data-id="term.id"
- rows="5"
+ t-att-rows="widget.showSrc ? 2 : 5"
class="o_field_text o_field_translate o_field_widget o_input">
</textarea>
</div>