diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-10-09 02:08:17 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-10-09 02:08:17 +0200 |
| commit | d74b4518052066446e2a1975a91ce53c745ca75a (patch) | |
| tree | c08180f88a512d15e37fbec628931f02b3825f65 /addons/web/static/src/js | |
| parent | ec162dd0cb5622ab6e56db1a63dab5f531a96f4a (diff) | |
| parent | f4105eb9c7010f8693e5d2fd20c0bd9d9cc2e483 (diff) | |
[MERGE] forward port branch 13.0 up to f4105eb9c70
Diffstat (limited to 'addons/web/static/src/js')
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(' ', '') - }); - 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 = { |
