summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js')
-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
29 files changed, 788 insertions, 448 deletions
diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js
index 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 = {