diff options
Diffstat (limited to 'addons/web/static/src')
16 files changed, 1012 insertions, 705 deletions
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index f36418e3b8b..0d7ed2f4fe2 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -32,7 +32,6 @@ odoo.define('web.AbstractField', function (require) { var field_utils = require('web.field_utils'); var Widget = require('web.Widget'); -var Domain = require('web.Domain'); var AbstractField = Widget.extend({ className: 'o_field_widget', @@ -40,22 +39,12 @@ var AbstractField = Widget.extend({ 'keydown': '_onKeydown', }, /** - * if this flag is set to true, the rest of the web client will assume that - * it is not editable. For example, the list view in editable mode will - * skip the widget when pressing tab - */ - readonly: false, - /** * If this flag is set to true, the field widget will be reset on every * change which is made in the view (if the view supports it). This is * currently a form view feature. */ resetOnAnyFieldChange: false, /** - * if true, the widget will replace a cell in an editable list view - */ - replace_element: false, - /** * If this flag is given a string, the related BasicModel will be used to * initialize specialData the field might need. This data will be available * through this.record.specialData[this.name]. @@ -77,8 +66,6 @@ var AbstractField = Widget.extend({ * @param {Object} record A record object (result of the get method of a basic model) * @param {Object} [options] * @param {string} [options.mode=readonly] should be 'readonly' or 'edit' - * @param {string} [options.required=false] - * @param {string} [options.idForLabel] */ init: function (parent, name, record, options) { this._super(parent); @@ -103,9 +90,6 @@ var AbstractField = Widget.extend({ var fieldsInfo = record.fieldsInfo[this.viewType]; this.attrs = fieldsInfo && fieldsInfo[name] || {}; - // modifiers - this.modifiers = JSON.parse(this.attrs.modifiers || "{}"); - // this property tracks the current (parsed if needed) value of the field. // Note that we don't use an event system anymore, using this.get('value') // is no longer valid. @@ -153,15 +137,6 @@ var AbstractField = Widget.extend({ // a string. this._isValid = true; - // the 'required' flag is basically only needed to determine if the widget - // is in a valid state (not valid if empty and required) - this.required = options.required || this.field.required; - - // the 'idForLabel' is the (html) id that should be set to the relevent - // dom entity. If done correctly, clicking on the corresponding label - // (in form view) will focus and select the value. - this.idForLabel = options.idForLabel; - // this is the last value that was set by the user, unparsed. This is // used to avoid setting the value twice in a row with the exact value. this.lastSetValue = undefined; @@ -175,8 +150,8 @@ var AbstractField = Widget.extend({ start: function () { var self = this; return this._super.apply(this, arguments).then(function () { - self._render(); self.$el.attr('name', self.name); + return self._render(); }); }, @@ -185,11 +160,22 @@ var AbstractField = Widget.extend({ //-------------------------------------------------------------------------- /** - * Right now, this function is only used in editable list view when the user - * select a cell. The corresponding widget will be activated, which in general - * means that the input text will be focused and selected + * Activates the field widget. By default, activation means focusing and + * selecting the associated focusable element. + * + * @returns {boolean} true if the widget was activated, false if the + * focusable element was not found or invisible */ activate: function () { + var $focusable = this.getFocusableElement(); + if ($focusable.length && $focusable.is(':visible')) { + $focusable.focus(); + if ($focusable.is('input[type="text"], textarea')) { + setTimeout($focusable.select.bind($focusable), 0); + } + return true; + } + return false; }, /** * This function should be implemented by widgets that are not able to @@ -202,6 +188,15 @@ var AbstractField = Widget.extend({ commitChanges: function () { }, /** + * Returns the main field's DOM element (jQuery form) which can be focused + * by the browser. + * + * @returns {jQuery} main focusable element inside the widget + */ + getFocusableElement: function () { + return $(); + }, + /** * this method is used to determine if the field value is set to a meaningful * value. This is useful to determine if a field should be displayed as empty * @@ -211,23 +206,23 @@ var AbstractField = Widget.extend({ return !!this.value; }, /** - * a field widget is valid if its value is valid and if there is a value when - * it is required. This is checked before saving a record, by the form view. + * A field widget is valid if it was checked as valid the last time its + * value was changed by the user. This is checked before saving a record, by + * the view. * - * Note: the return value may be a boolean. It is necessary for some fields - * (x2many) to sometimes ask confirmation to the user if the current dirty - * invalid line can be discarded. This is an asynchronous operation, so we - * need the expressivity of something like a deferred. + * Note: the return value may not be a boolean. It is necessary for some + * fields (x2many) to sometimes ask confirmation to the user if the current + * dirty invalid line can be discarded. This is an asynchronous operation, + * so we need the expressivity of something like a deferred. + * + * Note2: this is the responsability of the view to check that required + * fields have a set value. * * @returns {boolean|Deferred} true/false if the widget is valid, or a * deferred which resolves to true/false if the widget is valid or not */ isValid: function () { - var is_required = this.required; - if ('required' in this.modifiers) { - is_required = new Domain(this.modifiers.required).compute(this.recordData); - } - return this._isValid && !(is_required && !this.isSet()); + return this._isValid; }, /** * this method is supposed to be called from the outside of field widgets. @@ -285,12 +280,6 @@ var AbstractField = Widget.extend({ * @returns {Deferred} */ _render: function () { - var is_required = this.required; - if ('required' in this.modifiers) { - is_required = new Domain(this.modifiers.required).compute(this.recordData); - } - this.$el.toggleClass('o_form_required', is_required); - if (this.mode === 'edit') { return this._renderEdit(); } else if (this.mode === 'readonly') { @@ -347,7 +336,7 @@ var AbstractField = Widget.extend({ try { value = this._parseValue(value); this._isValid = true; - } catch(e) { + } catch (e) { this._isValid = false; } var changes = {}; diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index d2649195111..9f37240e0cc 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -122,13 +122,12 @@ var InputField = DebouncedField.extend({ //-------------------------------------------------------------------------- /** - * Automatically selects the content of the field. + * Returns the associated <input/> element. * * @override */ - activate: function () { - this.$input.focus(); - setTimeout(this.$input.select.bind(this.$input), 0); + getFocusableElement: function () { + return this.$input || $(); }, /** * Do not re-render this field if it was the origin of the onchange call. @@ -171,7 +170,6 @@ var InputField = DebouncedField.extend({ this.$input.addClass('o_form_input'); this.$input.attr({ type: 'text', - id: this.idForLabel, placeholder: this.attrs.placeholder || "", }); // Save cursor position to restore it after updating value @@ -253,7 +251,6 @@ var FieldChar = InputField.extend({ var FieldDate = InputField.extend({ className: "o_form_field_date", tagName: "span", - replace_element: true, supportedFieldTypes: ['date'], /** @@ -274,8 +271,7 @@ var FieldDate = InputField.extend({ }); def = this.datewidget.appendTo('<div>').done(function () { self.datewidget.$el.addClass(self.$el.attr('class')); - self.datewidget.$input.addClass('o_form_input'); - self.datewidget.$input.attr('id', self.idForLabel); + self._prepareInput(self.datewidget.$input); self.replaceElement(self.datewidget.$el); }); } @@ -371,7 +367,6 @@ var FieldDateTime = FieldDate.extend({ var FieldMonetary = InputField.extend({ className: 'o_form_field_monetary o_list_number', - replace_element: true, supportedFieldTypes: ['float', 'monetary'], resetOnAnyFieldChange: true, // Have to listen to currency changes @@ -484,7 +479,6 @@ var FieldBoolean = AbstractField.extend({ events: _.extend({}, AbstractField.prototype.events, { change: '_onChange', }), - replace_element: true, supportedFieldTypes: ['boolean'], //-------------------------------------------------------------------------- @@ -492,15 +486,12 @@ var FieldBoolean = AbstractField.extend({ //-------------------------------------------------------------------------- /** - * Automatically selects the field. - * * @override + * @returns {jQuery} the focusable checkbox input */ - activate: function () { - this.$input.focus(); - setTimeout(this.$input.select.bind(this.$input), 0); + getFocusableElement: function () { + return this.$input || $(); }, - /** * A boolean field is always set since false is a valid value. * @@ -686,7 +677,6 @@ var FieldText = DebouncedField.extend({ */ start: function () { this.$el.addClass('o_list_text o_form_textarea'); - this.$el.attr('id', this.idForLabel); if (this.mode === 'edit') { this.$textarea = $('<textarea>').appendTo(this.$el); @@ -704,14 +694,12 @@ var FieldText = DebouncedField.extend({ //-------------------------------------------------------------------------- /** - * Automatically selects the content of the field. + * Returns the associated <textarea/> element. * * @override */ - activate: function () { - var $textarea = this.$('textarea'); - $textarea.focus(); - setTimeout($textarea.select.bind($textarea), 0); + getFocusableElement: function () { + return this.$textarea || $(); }, //-------------------------------------------------------------------------- @@ -753,8 +741,6 @@ var HandleWidget = AbstractField.extend({ tagName: 'span', className: 'o_row_handle fa fa-arrows ui-sortable-handle', description: "", - readonly: true, - replace_element: true, supportedFieldTypes: ['integer'], }); @@ -1093,7 +1079,6 @@ var PriorityWidget = AbstractField.extend({ 'mouseout > a': '_onMouseOut', 'click > a': '_onClick', }, - readonly: true, supportedFieldTypes: ['selection'], //-------------------------------------------------------------------------- @@ -1195,7 +1180,6 @@ var PriorityWidget = AbstractField.extend({ this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star'); $(event.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o'); }, - }); var AttachmentImage = AbstractField.extend({ @@ -1210,7 +1194,6 @@ var StateSelectionWidget = AbstractField.extend({ }, 'click li': '_setSelection' }, - readonly: true, supportedFieldTypes: ['selection'], //-------------------------------------------------------------------------- @@ -1300,7 +1283,6 @@ var StateSelectionWidget = AbstractField.extend({ }); var LabelSelection = AbstractField.extend({ - readonly: true, supportedFieldTypes: ['selection'], //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 06f38abffbb..abe3de97779 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -116,18 +116,16 @@ var FieldMany2One = AbstractField.extend({ // Public //-------------------------------------------------------------------------- - activate: function () { - this.$input.focus(); - setTimeout(this.$input.select.bind(this.$input), 0); - }, /** - * Focuses the input. + * @override + * @returns {jQuery} */ - focus: function () { - if (this.mode === "edit") { - this.$input.focus(); - } + getFocusableElement: function () { + return this.$input || $(); }, + /** + * TODO + */ reinitialize: function (value) { this.floating = false; this._setValue(value); @@ -366,7 +364,7 @@ var FieldMany2One = AbstractField.extend({ disable_multiple_selection: true, on_selected: function (element_ids) { self.reinitialize({id: element_ids[0]}); - self.focus(); + self.activate(); } })).open(); }, @@ -409,7 +407,7 @@ var FieldMany2One = AbstractField.extend({ */ _onExternalButtonClick: function () { if (!this.value) { - this.focus(); + this.activate(); return; } var self = this; @@ -578,8 +576,7 @@ var FieldX2Many = AbstractField.extend({ } var def = $.Deferred(); this.renderer.canBeSaved() - .then(def.resolve.bind(def, true)) - .fail(def.resolve.bind(def, false)); + .then(def.resolve.bind(def, true), def.resolve.bind(def, false)); return def; }, @@ -610,6 +607,7 @@ var FieldX2Many = AbstractField.extend({ mode: this.mode, addCreateLine: !this.isReadonly && this.activeActions.create, addTrashIcon: !this.isReadonly, + viewType: 'list', }); } if (arch.tag === 'kanban') { @@ -620,7 +618,8 @@ var FieldX2Many = AbstractField.extend({ }; this.renderer = new KanbanRenderer(this, this.value, { arch: arch, - record_options: record_options + record_options: record_options, + viewType: 'kanban', }); } return this.renderer ? this.renderer.appendTo(this.$el) : this._super(); @@ -940,7 +939,6 @@ var FieldMany2Many = FieldX2Many.extend({ var FieldMany2ManyTags = AbstractField.extend({ tag_template: "FieldMany2ManyTag", className: "o_form_field o_form_field_many2manytags", - replace_element: true, supportedFieldTypes: ['many2many'], custom_events: { field_changed: '_onFieldChanged', @@ -957,9 +955,23 @@ var FieldMany2ManyTags = AbstractField.extend({ // Public //-------------------------------------------------------------------------- + /** + * @override + */ activate: function () { - this.many2one.focus(); + return this.many2one ? this.many2one.activate() : false; + }, + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + return this.many2one ? this.many2one.getFocusableElement() : $(); }, + /** + * @override + * @returns {boolean} + */ isSet: function () { return !!this.value && this.value.count; }, @@ -1012,7 +1024,6 @@ var FieldMany2ManyTags = AbstractField.extend({ this.many2one.destroy(); } this.many2one = new FieldMany2One(this, this.name, this.record, { - idForLabel: this.idForLabel, mode: 'edit', viewType: this.viewType, }); @@ -1343,7 +1354,6 @@ var FieldSelection = AbstractField.extend({ events: _.extend({}, AbstractField.prototype.events, { 'change': '_onChange', }), - replace_element: true, /** * @override */ @@ -1365,6 +1375,18 @@ var FieldSelection = AbstractField.extend({ /** * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + return this.$el.is('select') ? this.$el : $(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override * @private */ _renderEdit: function () { diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js index 83aafdfe310..70ad5350670 100644 --- a/addons/web/static/src/js/views/abstract_renderer.js +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -93,4 +93,3 @@ return Widget.extend({ }); }); - 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 c21d7a43335..5293ee6f4b6 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -88,6 +88,21 @@ return AbstractController.extend(FieldManagerMixin, { //-------------------------------------------------------------------------- /** + * Called by the field manager mixin to confirm that a change just occured + * (after that potential onchanges have been applied). + * + * Basically, this only relays the notification to the renderer with the + * new state. + * + * @param {string} id - the id of one of the view's records + * @param {string[]} fields - the changed fields + * @param {OdooEvent} e - the event that triggered the change + */ + _confirmChange: function (id, fields, e) { + var state = this.model.get(this.handle); + this.renderer.confirmChange(state, id, fields, e); + }, + /** * Delete records (and ask for confirmation if necessary) * * @param {string[]} ids list of local record ids 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 cd1dedb2f6c..ba51819b218 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -27,6 +27,8 @@ odoo.define('web.BasicModel', function (require) { * fieldsInfo: {Object}, * getContext: {function}, * getDomain: {function}, + * getEvalContext: {function}, + * getFieldNames: {function}, * groupedBy: {string[]}, * id: {integer}, * isOpen: {boolean}, @@ -2001,8 +2003,8 @@ var BasicModel = AbstractModel.extend({ }; dataPoint.getContext = this._getContext.bind(this, dataPoint); - dataPoint.getEvalContext = this._getEvalContext.bind(this, dataPoint); dataPoint.getDomain = this._getDomain.bind(this, dataPoint); + dataPoint.getEvalContext = this._getEvalContext.bind(this, dataPoint); dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint); this.localData[dataPoint.id] = dataPoint; diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index 1db0718545e..a18a82e5745 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -3,27 +3,86 @@ odoo.define('web.BasicRenderer', function (require) { /** * The BasicRenderer is an abstract class designed to share code between all - * views that uses a BasicModel. The main goal is to keep track of all field - * widgets, and properly destroy them whenever a rerender is done. - * - * When a concrete view extends a BasicRenderer, it should add each field - * widgets in the this.widget list. + * views that uses a BasicModel. The main goal is to keep track of all field + * widgets, and properly destroy them whenever a rerender is done. The widgets + * and modifiers updates mechanism is also shared in the BasicRenderer. */ - var AbstractRenderer = require('web.AbstractRenderer'); var config = require('web.config'); var core = require('web.core'); +var Domain = require('web.Domain'); var qweb = core.qweb; -return AbstractRenderer.extend({ +var BasicRenderer = AbstractRenderer.extend({ + custom_events: { + /** + * All basic renderers should be able to handle widget navigation + * through the TAB key. + */ + move_next: '_onMoveNext', + move_previous: '_onMovePrevious', + }, /** + * Basic renderers implements the concept of "mode", they can either be in + * readonly mode or editable mode. + * * @override */ - init: function (paret, state, params) { + init: function (parent, state, params) { this._super.apply(this, arguments); - this.widgets = []; this.activeActions = params.activeActions; + this.viewType = params.viewType; + this.mode = params.mode || 'readonly'; + }, + /** + * Updates the internal state of the renderer to the new state. By default, + * this also implements the recomputation of the modifiers and their + * application to the DOM and the reset of the field widgets if needed. + * + * In case the given record is not found anymore, a whole re-rendering is + * completed (possible if a change in a record caused an onchange which + * erased the current record). + * + * We could always rerender the view from scratch, but then it would not be + * as efficient, and we might lose some local state, such as the input focus + * cursor, or the scrolling position. + * + * @param {Object} state + * @param {string} id + * @param {string[]} fields + * @param {OdooEvent} ev + * @returns {Deferred<AbstractField[]>} resolved with the list of widgets + * that have been reset + */ + confirmChange: function (state, id, fields, ev) { + this.state = state; + + var record = state.id === id ? state : _.findWhere(state.data, {id: id}); + if (!record) { + return this._render(); + } + + var defs = []; + + // Reset all the field widgets that are marked as changed and the ones + // which are configured to always be reset on any change + var resetWidgets = []; + _.each(this.allFieldWidgets[id], function (widget) { + if (_.contains(fields, widget.name) || widget.resetOnAnyFieldChange) { + defs.push(widget.reset(record, ev)); + resetWidgets.push(widget); + } + }); + + // The modifiers update is done after widget resets as modifiers + // associated callbacks need to have all the widgets with the proper + // state before evaluation + defs.push(this._updateAllModifiers(record)); + + return $.when.apply($, defs).then(function () { + return resetWidgets; + }); }, //-------------------------------------------------------------------------- @@ -52,15 +111,358 @@ return AbstractRenderer.extend({ }); }, /** + * Activates the widget at the given index for the given record if possible + * or the "next" possible one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @param {integer} [inc=1] - the increment to use when searching for the + * "next" possible one + * @returns {integer} the index of the widget that was activated or -1 if + * none was possible to activate + */ + _activateFieldWidget: function (record, currentIndex, inc) { + inc = inc === undefined ? 1 : inc; + + var activated; + var recordWidgets = this.allFieldWidgets[record.id] || []; + for (var i = 0 ; i < recordWidgets.length ; i++) { + activated = recordWidgets[currentIndex].activate(); + if (activated) { + return currentIndex; + } + + currentIndex += inc; + if (currentIndex >= recordWidgets.length) { + currentIndex -= recordWidgets.length; + } else if (currentIndex < 0) { + currentIndex += recordWidgets.length; + } + } + return -1; + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the next possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @return {integer} + */ + _activateNextFieldWidget: function (record, currentIndex) { + currentIndex = (currentIndex + 1) % (this.allFieldWidgets[record.id] || []).length; + return this._activateFieldWidget(record, currentIndex, +1); + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the previous possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @return {integer} + */ + _activatePreviousFieldWidget: function (record, currentIndex) { + currentIndex = currentIndex ? (currentIndex - 1) : ((this.allFieldWidgets[record.id] || []).length - 1); + return this._activateFieldWidget(record, currentIndex, -1); + }, + /** + * Does the necessary DOM updates to match the given modifiers data. The + * modifiers data is supposed to contain the properly evaluated modifiers + * associated to the given records and elements. + * + * @param {Object} modifiersData + * @param {Object} record + * @param {Object} [element] - do the update only on this element if given + * @returns {Deferred} resolved once finished + */ + _applyModifiers: function (modifiersData, record, element) { + var self = this; + var defs = []; + var modifiers = modifiersData.evaluatedModifiers[record.id] || {}; + + this.defs = defs; // Potentially filled by widget rerendering + if (element) { + _apply(element); + } else { + // Clone is necessary as the list might change during _.each + _.each(_.clone(modifiersData.elementsByRecord[record.id]), _apply); + } + delete this.defs; + + return $.when.apply($, defs); + + function _apply(element) { + // If the view is in edit mode and that a widget have to switch + // its "readonly" state, we have to re-render it completely + if ('readonly' in modifiers + && self.mode === "edit" + && element.widget + && (element.widget.mode === 'readonly') !== modifiers.readonly) + { + self._rerenderFieldWidget(element.widget, record); + return; // Rerendering already applied the modifiers, no need to go further + } + + // Toggle modifiers CSS classes if necessary + element.$el.toggleClass("o_form_invisible", !!modifiers.invisible); + element.$el.toggleClass("o_readonly", !!modifiers.readonly); + element.$el.toggleClass("o_form_required", !!modifiers.required); + + // Call associated callback + if (element.callback) { + element.callback(element, modifiers, record); + } + } + }, + /** + * Determines if a given field widget value can be saved. For this to be + * true, the widget must be valid (properly parsed value) and have a value + * if the associated view field is required. + * + * @private + * @param {AbstractField} widget + * @returns {boolean|Deferred<boolean>} @see AbstractField.isValid + */ + _canWidgetBeSaved: function (widget) { + var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record); + var isSetOrNotRequired = (widget.isSet() || !modifiers.required); + var isValid = widget.isValid(); + if (isValid instanceof $.Deferred) { + return isValid.then(function (isValid) { + return isValid && isSetOrNotRequired; + }); + } + return isValid && isSetOrNotRequired; + }, + /** + * Updates the modifiers evaluation associated to a given modifiers data and + * a given record. This only updates the modifiers values. To see associated + * DOM updates: @see _updateAllModifiers @see _applyModifiers. + * + * @private + * @param {Object} modifiersData + * @param {Object} record + */ + _computeModifiers: function (modifiersData, record) { + var evalContext = record.getEvalContext(); + modifiersData.evaluatedModifiers[record.id] + = _.mapObject(modifiersData.modifiers, function (modifier) { + return new Domain(modifier, evalContext).compute(evalContext); + }); + }, + /** + * Destroys a given widget associated to the given record and removes it + * from internal referencing. + * + * @private + * @param {Object} record + * @param {AbstractField} widget + * @returns {integer} the index of the removed widget + */ + _destroyFieldWidget: function (record, widget) { + var recordWidgets = this.allFieldWidgets[record.id]; + var index = recordWidgets.indexOf(widget); + if (index >= 0) { + recordWidgets.splice(index, 1); + } + this._unregisterModifiersElement(widget.__node, record, widget); + widget.$el.destroy(); + return index; + }, + /** + * Searches for the last evaluation of the modifiers associated to the given + * data (modifiers evaluation are supposed to always be up-to-date as soon + * as possible). + * + * @private + * @param {Object} node + * @param {Object} record + * @returns {Object} the evaluated modifiers associated to the given node + * and record (not recomputed by the call) + */ + _getEvaluatedModifiers: function (node, record) { + var element = this._getModifiersData(node); + if (!element) { + return {}; + } + return element.evaluatedModifiers[record.id] || {}; + }, + /** + * Searches through the registered modifiers data for the one which is + * related to the given node. + * + * @private + * @param {Object} node + * @returns {Object|undefined} related modifiers data if any + * undefined otherwise + */ + _getModifiersData: function (node) { + return _.findWhere(this.allModifiersData, {node: node}); + }, + /** + * Registers or updates the modifiers data associated to the given node. + * This method is quiet complex as it handles all the needs of the basic + * renderers: + * + * - On first registration, the modifiers are evaluated thanks to the given + * record. This allows nodes that will produce an AbstractField instance + * to have their modifiers registered before this field creation as we + * need the readonly modifier to be able to instantiate the AbstractField. + * (@see _computeModifiers). + * + * - On additional registrations, if the node was already registered but the + * record is different, we evaluate the modifiers for this record and + * saves them in the same object (without reparsing the modifiers). + * + * - On additional registrations, the modifiers are not reparsed (or + * reevaluated for an already seen record) but the given widget or DOM + * element is associated to the node modifiers. + * + * - The new elements are immediately adapted to match the modifiers and the + * given associated callback is called even if there is no modifiers on + * the node (@see _applyModifiers). This is indeed necessary as the + * callback is a description of what to do when a modifier changes. Even + * if there is no modifiers, this action must be performed on first + * rendering to avoid code duplication. If there is no modifiers, they + * will however not be registered for modifiers updates. + * + * - When a new element is given, it does not replace the old one, it is + * added as an additional element. This is indeed useful for nodes that + * will produce multiple DOM (as a list cell and its internal widget or + * a form field and its associated label). + * (@see _unregisterModifiersElement for removing an associated element.) + * + * Note: also on view rerendering, all the modifiers are forgotten so that + * the renderer only keeps the ones associated to the current DOM state. + * + * @private + * @param {Object} node + * @param {Object} record + * @param {jQuery|AbstractField} [element] + * @param {Object} [options] + * @param {Object} [options.callback] - the callback to call on registration + * and on modifiers updates + * @returns {Object} for code efficiency, returns the last evaluated + * modifiers for the given node and record. + */ + _registerModifiers: function (node, record, element, options) { + // Check if we already registered the modifiers for the given node + // If yes, this is simply an update of the related element + // If not, check the modifiers to see if it needs registration + var modifiersData = this._getModifiersData(node); + if (!modifiersData) { + var modifiers = JSON.parse(node.attrs.modifiers || "{}"); // FIXME parsed multiple times (record switching, no modifiers, ...) + modifiersData = { + node: node, + modifiers: modifiers, + evaluatedModifiers: {}, + elementsByRecord: {}, + }; + if (!_.isEmpty(modifiers)) { // Register only if modifiers might change (TODO condition might be improved here) + this.allModifiersData.push(modifiersData); + } + } + + // Evaluate if necessary + if (!modifiersData.evaluatedModifiers[record.id]) { + this._computeModifiers(modifiersData, record); + } + + // Element might not be given yet (a second call to the function can + // update the registration with the element) + if (element) { + var newElement = {}; + if (element instanceof jQuery) { + newElement.$el = element; + } else { + newElement.widget = element; + newElement.$el = element.$el; + } + if (options && options.callback) { + newElement.callback = options.callback; + } + + if (!modifiersData.elementsByRecord[record.id]) { + modifiersData.elementsByRecord[record.id] = []; + } + modifiersData.elementsByRecord[record.id].push(newElement); + + this._applyModifiers(modifiersData, record, newElement); + } + + return modifiersData.evaluatedModifiers[record.id]; + }, + /** * Render the view * * @override * @returns {Deferred} */ _render: function () { - var oldWidgets = this.widgets; - this.widgets = []; - return this._renderView().then(_.invoke.bind(_, oldWidgets, 'destroy')); + var oldAllFieldWidgets = this.allFieldWidgets; + this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great + this.allModifiersData = []; + return this._renderView().then(function () { + _.each(oldAllFieldWidgets, function (recordWidgets) { + _.each(recordWidgets, function (widget) { + widget.destroy(); + }); + }); + }); + }, + /** + * Instantiates the appropriate AbstractField specialization for the given + * node and prepares its rendering and addition to the DOM. Indeed, the + * rendering of the widget will be started and the associated deferred will + * be added to the 'defs' attribute. This is supposed to be created and + * deleted by the calling code if necessary. + * Note: for this implementation to work, AbstractField willStart methods + * *must* be synchronous. + * + * @private + * @param {Object} node + * @param {Object} record + * @param {Object} [options] + * @param {Object} [modifiersOptions] + * @returns {AbstractField} + */ + _renderFieldWidget: function (node, record, options, modifiersOptions) { + var fieldName = node.attrs.name; + + // Register the node-associated modifiers + var modifiers = this._registerModifiers(node, record); + + // Initialize and register the widget + // Readonly status is known as the modifiers have just been registered + var Widget = record.fieldsInfo[this.viewType][fieldName].Widget; + var widget = new Widget(this, fieldName, record, _.extend({ + mode: modifiers.readonly ? 'readonly' : this.mode, + viewType: this.viewType, + }, options || {})); + + // Register the widget so that it can easily be found again + if (this.allFieldWidgets[record.id] === undefined) { + this.allFieldWidgets[record.id] = []; + } + this.allFieldWidgets[record.id].push(widget); + + widget.__node = node; // TODO get rid of this if possible one day + + // Prepare widget rendering and save the related deferred + var def = widget.__widgetRenderAndInsert(function () {}); + if (def.state() === 'pending') { + this.defs.push(def); + } + + // Update the modifiers registration by associating the widget and by + // giving the modifiers options now (as the potential callback is + // associated to new widget) + this._registerModifiers(node, record, widget, modifiersOptions); + + return widget; }, /** * Actual rendering. Supposed to be overridden by concrete renderers. @@ -69,7 +471,7 @@ return AbstractRenderer.extend({ * - instantiate a widget from the registry for each field in the arch * * Note that the 'state' field should contains all necessary information - * for the rendering. The field widgets should be as synchronous as + * for the rendering. The field widgets should be as synchronous as * possible. * * @abstract @@ -78,6 +480,98 @@ return AbstractRenderer.extend({ _renderView: function () { return $.when(); }, + /** + * Rerenders a given widget and make sure the associated data which + * referenced the old one is updated. + * + * @private + * @param {Widget} widget + * @param {Object} record + * @returns {AbstractField} + */ + _rerenderFieldWidget: function (widget, record) { + // Render the new field widget + var newWidget = this._renderFieldWidget(widget.__node, record); + widget.$el.replaceWith(newWidget.$el); + + // Destroy the old widget and position the new one at the old one's + var oldIndex = this._destroyFieldWidget(record, widget); + var recordWidgets = this.allFieldWidgets[record.id]; + recordWidgets.splice(oldIndex, 0, newWidget); + recordWidgets.pop(); + + return newWidget; + }, + /** + * Unregisters an element of the modifiers data associated to the given + * node and record. + * + * @param {Object} node + * @param {Object} record + * @param {jQuery|AbstractField} element + */ + _unregisterModifiersElement: function (node, record, element) { + var modifiersData = this._getModifiersData(node); + if (modifiersData) { + var elements = modifiersData.elementsByRecord[record.id]; + var index = _.findIndex(elements, function (oldElement) { + return oldElement.widget === element + || oldElement.$el[0] === element[0]; + }); + if (index >= 0) { + elements.splice(index, 1); + } + } + }, + /** + * Does two actions, for each registered modifiers: + * 1) Recomputes the modifiers associated to the given record and saves them + * (as boolean values) in the appropriate modifiers data. + * 2) Updates the rendering of the view elements associated to the given + * record to match the new modifiers. + * + * @see _computeModifiers + * @see _applyModifiers + * + * @private + * @param {Object} record + * @returns {Deferred} resolved once finished + */ + _updateAllModifiers: function (record) { + var self = this; + + var defs = _.flatten(_.map(this.allModifiersData, function (modifiersData) { + self._computeModifiers(modifiersData, record); + return self._applyModifiers(modifiersData, record); + })); + + return $.when.apply($, defs); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When someone presses TAB in a widget, it is nice to be able to go to the + * next widget, especially since the TAB key event is preventDefaulted + * + * @abstract + * @private + * @param {OdooEvent} ev + */ + _onMoveNext: function (ev) {}, + /** + * When someone presses SHIFT+TAB in a widget, it is nice to be able to go + * back to the previous widget, especially since the TAB key event is + * preventDefaulted + * + * @abstract + * @private + * @param {OdooEvent} ev + */ + _onMovePrevious: function (ev) {}, }); +return BasicRenderer; }); 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 d850b2b7a2f..2604dcf0aa1 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -19,6 +19,7 @@ var BasicView = AbstractView.extend({ Model: BasicModel, Controller: BasicController, }), + viewType: undefined, /** * process the fields_view to find all fields appearing in the views. * list those fields' name in this.fields_name, which will be the list @@ -34,6 +35,8 @@ var BasicView = AbstractView.extend({ init: function (viewInfo, params) { this._super.apply(this, arguments); + this.rendererParams.viewType = this.viewType; + this.controllerParams.confirmOnDelete = true; this.controllerParams.archiveEnabled = 'active' in viewInfo.fields; this.controllerParams.hasButtons = diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js index 9fcd34ffee2..06997c1c374 100644 --- a/addons/web/static/src/js/views/field_manager_mixin.js +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -12,6 +12,7 @@ var BasicModel = require('web.BasicModel'); var FieldManagerMixin = { custom_events: { + discard_changes: '_onDiscardChanges', discard_line: '_onDiscardLine', field_changed: '_onFieldChanged', load: '_onLoad', @@ -60,6 +61,20 @@ var FieldManagerMixin = { //-------------------------------------------------------------------------- /** + * Controlled elements might want to ask for a discard of the changes made + * to a whole record (see editable list view). In that case, this handler + * is called and call the appropriate @see BasicModel.discardChanges + * function. The result is then notified thanks to a call to + * @see _confirmChange. + * + * @private + * @param {OdooEvent} ev + */ + _onDiscardChanges: function (ev) { + this.model.discardChanges(ev.data.id); + this._confirmChange(ev.data.id, [], ev); // TODO get real list of changes ? + }, + /** * In some case, we may need to remove an element from a list, without going * through the notifyChanges machinery. The motivation for this is when the * user click on 'Add an item' in a field one2many with a required field, diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index f97a93e14e8..f83fdd78bcd 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -295,17 +295,6 @@ var FormController = BasicController.extend({ return this.alive(def); }, /** - * @override method from FieldManagerMixin - * @private - * @param {string} id it is ignored, because we already know it. - * @param {string[]} fields - * @param {OdooEvent} event the event that triggered the change - */ - _confirmChange: function (id, fields, event) { - var record = this.model.get(this.handle); - this.renderer.updateWidgets(fields, record, event); - }, - /** * When a save operation has been confirmed from the model, this method is * called. * @@ -326,7 +315,7 @@ var FormController = BasicController.extend({ return _.isObject(d) && (d.id === id || _.findWhere(d.data, {id: id})); }); - this.renderer.updateWidgets([fieldsChanged], record); + this.renderer.confirmChange(record, record.id, [fieldsChanged]); } }, /** @@ -588,7 +577,7 @@ var FormController = BasicController.extend({ this.model.setSort(event.data.id, event.data.name); var field = event.data.field; var state = this.model.get(this.handle); - this.renderer.updateWidgets([field], state); + this.renderer.confirmChange(state, state.id, [field]); }, }); diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index ff02fc85337..05d8dd2d5b2 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -4,7 +4,6 @@ odoo.define('web.FormRenderer', function (require) { var BasicRenderer = require('web.BasicRenderer'); var config = require('web.config'); var core = require('web.core'); -var Domain = require('web.Domain'); var _t = core._t; var qweb = core.qweb; @@ -13,23 +12,14 @@ var FIELD_CLASSES = { 'one2many': 'o_field_one2many', }; -return BasicRenderer.extend({ +var FormRenderer = BasicRenderer.extend({ className: "o_form_view", - custom_events: { - move_next: '_onMoveNext', - move_previous: '_onMovePrevious', - }, /** - * @override method from BasicRenderer - * @param {Object} params - * @param {string} params.mode either 'readonly' on 'edit' + * @override */ init: function (parent, state, params) { this._super.apply(this, arguments); this.idsForLabels = {}; - this.widgets = []; - this.mode = params.mode; - this.field_values = this.state.getEvalContext(); }, //-------------------------------------------------------------------------- @@ -60,8 +50,8 @@ return BasicRenderer.extend({ $label.toggleClass('o_form_invalid', !isValid); } - _.each(this.widgets, function (widget) { - var isValid = widget.isValid(); + _.each(this.allFieldWidgets[this.state.id], function (widget) { + var isValid = self._canWidgetBeSaved(widget); if (isValid instanceof $.Deferred) { defs.push(isValid.then(function (isValid) { markWidget(widget, isValid); @@ -81,8 +71,10 @@ return BasicRenderer.extend({ * environment with their current value (useful for widgets that can't * detect when their value changes, e.g. field 'html'). */ - commitChanges: function () { - _.invoke(this.widgets, 'commitChanges'); + commitChanges: function () { // TODO add a test + _.each(this.allFieldWidgets, function (recordWidgets) { + _.invoke(recordWidgets, 'commitChanges'); + }); }, /** * returns the active tab pages for each notebook @@ -139,38 +131,13 @@ return BasicRenderer.extend({ // if fieldNames are given, we update the corresponding field widget. // I think this is wrong, and the caller could directly call the - // updateWidgets method + // confirmChange method if (params.fieldNames) { // only update the given fields - return this.updateWidgets(params.fieldNames, state); + return this.confirmChange(state, state.id, params.fieldNames); } - this.field_values = state.getEvalContext(); return this._super.apply(this, arguments); }, - /** - * Update some field widgets with a new state. We could rerender the form - * view from scratch, but then it would not be as efficient, and we might - * lose some local state, such as the input focus cursor, or the scrolling - * position. - * - * @param {string[]} fields list of fields to be updated - * @param {Object} state new state - * @param {OdooEvent} [event] the event that triggered the change - * @returns {Deferred} - */ - updateWidgets: function (fields, state, event) { - this.state = state; - this.field_values = this.state.getEvalContext(); - var defs = []; - _.each(this.widgets, function (widget) { - if (_.contains(fields, widget.name) || widget.resetOnAnyFieldChange) { - defs.push(widget.reset(state, event)); - } - }); - // re-evalute the modifiers with the new state - this._evaluateModifiers(); - return $.when.apply($, defs); - }, //-------------------------------------------------------------------------- // Private @@ -181,16 +148,9 @@ return BasicRenderer.extend({ * * @private * @param {Object} widget a field widget - * @param {Object} field description of the field - */ - _addFieldClassNames: function (widget, field) { - widget.$el.addClass('o_form_field'); - // classname 'o_form_field_empty' hides the widget, so we apply it in - // on existing records for fields with no value, in readonly mode or if the - // field is readonly - if (this.state.data.id && !widget.isSet() && (this.mode === 'readonly' || field.readonly)) { - widget.$el.addClass('o_form_field_empty'); - } + */ + _addFieldClassNames: function (widget) { + widget.$el.addClass('o_form_field'); // TODO will be removed in the CSS update }, /** * @private @@ -209,21 +169,6 @@ return BasicRenderer.extend({ }, /** * @private - */ - _evaluateModifiers: function () { - var self = this; - _.each(this.nodeModifiers, function (d) { - if ('invisible' in d.modifiers) { - var is_invisible = new Domain(d.modifiers.invisible).compute(self.field_values); - d.$el.toggleClass('o_form_invisible', is_invisible); - if (d.onEvalCallback) { - d.onEvalCallback({invisible: is_invisible}); - } - } - }); - }, - /** - * @private * @param {string} name * @returns {string} */ @@ -239,16 +184,8 @@ return BasicRenderer.extend({ * @private * @param {jQueryElement} $el * @param {Object} node - * @param {function} onEvalCallback - */ - _handleAttributes: function ($el, node, onEvalCallback) { - // register the modifiers with their corresponding $el so that they can - // be re-evaluated at each field changed - this.nodeModifiers.push({ - $el: $el, - modifiers: JSON.parse(node.attrs.modifiers || "{}"), - onEvalCallback: onEvalCallback, - }); + */ + _handleAttributes: function ($el, node) { if (node.attrs.class) { $el.addClass(node.attrs.class); } @@ -287,7 +224,7 @@ return BasicRenderer.extend({ // Get the folded buttons var folded_buttons = visible_buttons.slice(nb_buttons); - if(folded_buttons.length === 1) { + if (folded_buttons.length === 1) { unfolded_buttons = buttons; folded_buttons = []; } @@ -302,7 +239,7 @@ return BasicRenderer.extend({ }); // Add the dropdown with folded buttons if any - if(folded_buttons.length) { + if (folded_buttons.length) { $result.append($("<button>", { type: 'button', 'class': "btn btn-sm oe_stat_button o_button_more dropdown-toggle", @@ -318,36 +255,35 @@ return BasicRenderer.extend({ } this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); return $result; }, /** + * @override * @private - * @param {Object} node - * @returns {FieldWidget} + * @param {string} node + * @param {Object} record + * @param {Object} [options] + * @returns {AbstractField} */ - _renderFieldWidget: function (node) { - var name = node.attrs.name; - var field = this.state.fields[name]; + _renderFieldWidget: function (node, record, options, modifiersOptions) { + var self = this; + modifiersOptions = _.extend({ + callback: function (element, modifiers, record) { + element.$el.toggleClass('o_form_field_empty', !!( // FIXME condition is evaluated twice (label AND widget...) + record.data.id + && (modifiers.readonly || self.mode === 'readonly') + && !element.widget.isSet() + )); + }, + }, modifiersOptions || {}); - var modifiers = JSON.parse(node.attrs.modifiers || "{}"); - var is_readonly = new Domain(modifiers.readonly).compute(this.field_values); - var is_required = field.required || new Domain(modifiers.required).compute(this.field_values); - var options = { - mode: is_readonly ? 'readonly' : this.mode, - required: is_required, - idForLabel: this._getIDForLabel(name), - viewType: 'form', - }; - var attrs = this.state.fieldsInfo.form[name]; - var widget = new attrs.Widget(this, name, this.state, options); - this.widgets.push(widget); - var def = widget.__widgetRenderAndInsert(function () {}); - if (def.state() === 'pending') { - this.defs.push(def); - } - widget.$el.addClass(FIELD_CLASSES[field.type]); + var widget = this._super(node, record, options, modifiersOptions); + widget.getFocusableElement().attr('id', this._getIDForLabel(node.attrs.name)); + + widget.$el.addClass(FIELD_CLASSES[record.fields[node.attrs.name].type]); + this._addFieldClassNames(widget); this._handleAttributes(widget.$el, node); - this._addFieldClassNames(widget, field); return widget; }, @@ -357,13 +293,9 @@ return BasicRenderer.extend({ * @returns {jQueryElement} */ _renderGenericTag: function (node) { - var $result = $('<' + node.tag + '>'); - _.each(node.attrs, function (attr, name) { - if (name !== 'class' && name !== 'modifiers') { - $result.attr(name, attr); - } - }); + var $result = $('<' + node.tag + '>', _.omit(node.attrs, 'modifiers')); this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); $result.append(_.map(node.children, this._renderNode.bind(this))); return $result; }, @@ -378,6 +310,7 @@ return BasicRenderer.extend({ .addClass('btn btn-sm btn-default'); this._addOnClickAction($button, node); this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); return $button; }, /** @@ -386,26 +319,35 @@ return BasicRenderer.extend({ * @returns {jQueryElement} */ _renderInnerField: function (node) { - var field = this.state.fields[node.attrs.name]; + var self = this; + var fieldName = node.attrs.name; + var field = this.state.fields[fieldName]; var fieldDescr = node.attrs.string || field.string; - var hasLabel = !node.attrs.nolabel; - var widget = this._renderFieldWidget(node); - if (hasLabel) { - var $label = $('<label>') - .addClass('o_form_label') - .attr('for', this._getIDForLabel(node.attrs.name)) - .text(fieldDescr); - var isReadOnly = this.mode === 'readonly' || field.readonly; - if (this.state.data.id && !widget.isSet() && isReadOnly) { - $label.addClass('o_form_label_empty'); - } - this._handleAttributes($label, node); + var widget = this._renderFieldWidget(node, this.state); + + if (!node.attrs.nolabel) { + var $label = $('<label>', { + class: 'o_form_label', + for: this._getIDForLabel(node.attrs.name), + text: fieldDescr, + }); + this._registerModifiers(node, this.state, $label, { + callback: function (element, modifiers, record) { + var recordWidgets = self.allFieldWidgets[record.id]; + var widget = _.findWhere(recordWidgets, {name: fieldName}); + element.$el.toggleClass('o_form_label_empty', !!( + record.data.id + && (modifiers.readonly || self.mode === 'readonly') + && !widget.isSet() // getting like this because it could have been re-rendered... + )); + }, + }); return $('<tr>') .append($('<td class="o_td_label">').append($label)) .append($('<td style="width: 100%">').append(widget.$el)); } else { var style = { - width: 100/2 + '%' + width: 100/2 + '%', }; return $('<tr>') .append($('<td colspan="1">').css(style).append(widget.$el)); @@ -419,6 +361,7 @@ return BasicRenderer.extend({ _renderInnerGroup: function (node) { var $result = $('<table class="o_group o_inner_group"/>'); this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); if (node.attrs.string) { var $sep = $('<tr><td colspan="2" style="width:100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>'); $result.append($sep); @@ -522,6 +465,7 @@ return BasicRenderer.extend({ $button.append(_.map(node.children, this._renderNode.bind(this))); this._addOnClickAction($button, node); this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); return $button; }, /** @@ -568,6 +512,7 @@ return BasicRenderer.extend({ $button.append(_.map(node.children, this._renderNode.bind(this))); this._addOnClickAction($button, node); this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); return $button; }, /** @@ -576,7 +521,7 @@ return BasicRenderer.extend({ * @returns {jQueryElement} */ _renderTagField: function (node) { - return this._renderFieldWidget(node).$el; + return this._renderFieldWidget(node, this.state).$el; }, /** * @private @@ -617,6 +562,7 @@ return BasicRenderer.extend({ $child.appendTo($result); }); this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); return $result; }, /** @@ -633,7 +579,7 @@ return BasicRenderer.extend({ if (child.tag === 'button') { $buttons.append(self._renderHeaderButton(child)); } else if (child.tag === 'field') { - var widget = self._renderFieldWidget(child); + var widget = self._renderFieldWidget(child, self.state); $statusbar.append(widget.$el); } }); @@ -658,6 +604,7 @@ return BasicRenderer.extend({ .attr('for', this._getIDForLabel(node.attrs.for)) .text(text); this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); return $result; }, /** @@ -677,15 +624,18 @@ return BasicRenderer.extend({ $header.addClass('active'); $page.addClass('active'); } - self._handleAttributes($header, child, function onEvalCallback (attrs) { - // if the active tab is invisible, activate the first visible tab instead - if (attrs.invisible && $header.hasClass('active')) { - $header.removeClass('active'); - $page.removeClass('active'); - var $firstVisibleTab = $headers.find('li:not(.o_form_invisible):first()'); - $firstVisibleTab.addClass('active'); - $pages.find($firstVisibleTab.find('a').attr('href')).addClass('active'); - } + self._handleAttributes($header, child); + self._registerModifiers(child, self.state, $header, { + callback: function (element, modifiers, record) { + // if the active tab is invisible, activate the first visible tab instead + if (modifiers.invisible && element.$el.hasClass('active')) { + element.$el.removeClass('active'); + $page.removeClass('active'); + var $firstVisibleTab = $headers.find('li:not(.o_form_invisible):first()'); + $firstVisibleTab.addClass('active'); + $pages.find($firstVisibleTab.find('a').attr('href')).addClass('active'); + } + }, }); $headers.append($header); $pages.append($page); @@ -727,34 +677,31 @@ return BasicRenderer.extend({ */ _renderView: function () { var self = this; - this.defs = []; - this.nodeModifiers = []; // render the form and evaluate the modifiers + var defs = []; + this.defs = defs; var $form = this._renderNode(this.arch).addClass(this.className); - this._evaluateModifiers(); - - var defs = this.defs; delete this.defs; + return $.when.apply($, defs).then(function () { self.$el.html($form.contents()); - // necessary to allow all sub widgets to use their dimensions in layout - // related activities, such as autoresize on fieldtexts + // Necessary to allow all sub widgets to use their dimensions in + // layout related activities, such as autoresize on fieldtexts core.bus.trigger('DOM_updated'); self.$el.toggleClass('o_form_nosheet', !self.has_sheet); self.$el.toggleClass('o_form_editable', self.mode === 'edit'); self.$el.toggleClass('o_form_readonly', self.mode === 'readonly'); // Attach the tooltips on the fields' label - _.each(self.widgets, function (widget) { + _.each(self.allFieldWidgets[self.state.id], function (widget) { if (core.debug || widget.attrs.help || widget.field.help) { var idForLabel = self.idsForLabels[widget.name]; var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); self._addFieldTooltip(widget, $label); } }); - }); }, @@ -763,34 +710,26 @@ return BasicRenderer.extend({ //-------------------------------------------------------------------------- /** - * When someone presses TAB in a widget, it is nice to be able to go to the - * next widget, especially since the TAB key event is preventDefaulted - * + * @override * @private - * @param {OdooEvent} event + * @param {OdooEvent} ev */ - _onMoveNext: function (event) { - event.stopPropagation(); - var index = this.widgets.indexOf(event.target); - if (index + 1 < this.widgets.length) { - this.widgets[index + 1].activate(); - } + _onMoveNext: function (ev) { + ev.stopPropagation(); + var index = this.allFieldWidgets[this.state.id].indexOf(ev.target); + this._activateNextFieldWidget(this.state, index); }, /** - * When someone presses SHIFT+TAB in a widget, it is nice to be able to go - * back to the previous widget, especially since the TAB key event is - * preventDefaulted - * + * @override * @private * @param {OdooEvent} event */ - _onMovePrevious: function (event) { - event.stopPropagation(); - var index = this.widgets.indexOf(event.target); - if (index >= 1) { - this.widgets[index - 1].activate(); - } + _onMovePrevious: function (ev) { + ev.stopPropagation(); + var index = this.allFieldWidgets[this.state.id].indexOf(ev.target); + this._activatePreviousFieldWidget(this.state, index); }, }); +return FormRenderer; }); diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js index 9e4e6865c0f..5c976bba903 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -89,6 +89,7 @@ var KanbanRenderer = BasicRenderer.extend({ init: function (parent, state, params) { this._super.apply(this, arguments); + this.widgets = []; this.qweb = new QWeb(session.debug, {_s: session.origin}); var templates = findInNode(this.arch, function (n) { return n.tag === 'templates';}); transformQwebTemplate(templates, state.fields); 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 50bb74334c4..5dcc2300eac 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -159,19 +159,6 @@ var ListController = BasicController.extend({ }, /** * This function is the hook called by the field manager mixin to confirm - * that a change just occured (after the eventual onchange have been - * applied). - * - * @param {string} id a basicmodel id, which is a record in the list view - * @param {string[]} fields the changed fields - * @param {OdooEvent} event the event that triggered the change - */ - _confirmChange: function (id, fields, event) { - var state = this.model.get(this.handle); - this.renderer.confirmChange(state, id, fields, event); - }, - /** - * This function is the hook called by the field manager mixin to confirm * that a record has been saved. * * @override 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 1e24aaeb879..df7a953c632 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 @@ -4,31 +4,27 @@ odoo.define('web.EditableListRenderer', function (require) { /** * Editable List renderer * - * The list renderer is reasonably complex, so we split it in two files. This + * The list renderer is reasonably complex, so we split it in two files. This * file simply 'includes' the basic ListRenderer to add all the necessary - * behavior to enable editing records. + * behaviors to enable editing records. * * Unlike Odoo v10 and before, this list renderer is independant from the form - * view. It uses the same widgets, but the code is totally stand alone. + * view. It uses the same widgets, but the code is totally stand alone. */ - var core = require('web.core'); var Dialog = require('web.Dialog'); -var Domain = require('web.Domain'); var ListRenderer = require('web.ListRenderer'); var _t = core._t; ListRenderer.include({ - custom_events: { - field_changed: '_onFieldChanged', + custom_events: _.extend({}, ListRenderer.prototype.custom_events, { move_down: '_onMoveDown', move_up: '_onMoveUp', move_left: '_onMoveLeft', move_right: '_onMoveRight', - move_next: '_onMoveNext', move_next_line: '_onMoveNextLine', - }, + }), events: _.extend({}, ListRenderer.prototype.events, { 'click tbody td': '_onCellClick', 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick', @@ -39,15 +35,12 @@ ListRenderer.include({ /** * @override * @param {Object} params - * @param {string} params.mode either 'readonly' or 'edit' * @param {boolean} params.addCreateLine * @param {boolean} params.addTrashIcon */ init: function (parent, state, params) { this._super.apply(this, arguments); - this.mode = params.mode; - // if addCreateLine is true, the renderer will add a 'Add an item' link // at the bottom of the list view this.addCreateLine = params.addCreateLine; @@ -88,19 +81,22 @@ ListRenderer.include({ var self = this; var invalidDirtyWidgets = []; var invalidCleanWidgets = []; - _.each(this.widgets, function (widget) { - // Note: the isValid method may return a deferred in general, but - // not in this case. Here, this method is called by a fieldone2many - // when it need to make sure the state is valid. So, the widgets - // that we consider here are widgets that can be in a one2many, and - // we don't support a list inside a list (for now at least). - if (!widget.isValid()) { - if (self.dirtyRows[widget.dataPointID]) { - invalidDirtyWidgets.push(widget); - } else { - invalidCleanWidgets.push(widget); + _.each(this.allFieldWidgets, function (recordWidgets, recordID) { + _.each(recordWidgets, function (widget) { + // Note: the _canWidgetBeSaved method may return a deferred in + // general, but not in this case. Here, this method is called by + // a fieldone2many when it needs to make sure the state is + // valid. So, the widgets that we consider here are widgets that + // can be in a one2many, and we don't support a list inside a + // list (for now at least). + if (!self._canWidgetBeSaved(widget)) { + if (self.dirtyRows[recordID]) { + invalidDirtyWidgets.push(widget); + } else { + invalidCleanWidgets.push(widget); + } } - } + }); }); if (invalidDirtyWidgets.length) { // TODO: ask for confirmation, open dialog, and depending on user @@ -112,29 +108,29 @@ ListRenderer.include({ return $.when(); }, /** - * This method is called by the controller, when a change has been confirmed - * by the model. In that situation, we need to properly update the cell - * widgets. (it is necessary, because the value could have been changed by - * an onchange). + * @see BasicRenderer.confirmChange * - * Note that it could happen that the changedRecordID is not found in the - * current state. This is possible if a change in a record caused an - * onchange which erased the current record. In that case, we simply - * rerender the list. + * We also react to changes by adding the 'o_field_dirty' css class, + * which changes its style to a visible orange outline. It allows the user + * to have a visual feedback on which fields changed, and on which fields + * have been saved. * - * @param {Object} state a record resource (the new full state) - * @param {string} changedRecordID the local id for the changed record - * @param {string[]} changedFields list of modified fields - * @param {OdooEvent} event the event that triggered the change + * @override + * @param {Object} state - a record resource (the new full state) + * @param {string} id - the local id for the changed record + * @param {string[]} fields - list of modified fields + * @param {OdooEvent} e - the event that triggered the change + * @returns {Deferred} */ - confirmChange: function (state, changedRecordID, changedFields, event) { - this.state = state; - var rowIndex = _.findIndex(this.state.data, {id: changedRecordID}); - if (rowIndex > -1) { - this._updateRow(rowIndex, changedFields, event); - } else { - this._render(); - } + confirmChange: function (state, id, fields, e) { + this.dirtyRows[id] = true; + + return this._super.apply(this, arguments).then(function (resetWidgets) { + _.each(resetWidgets, function (widget) { + var $td = widget.$el.parent(); + $td.addClass('o_field_dirty'); + }); + }); }, /** * This method is called by the controller when the user has clicked 'save', @@ -179,7 +175,7 @@ ListRenderer.include({ * @returns {jQueryElement} the $td for the index-th field */ _findTd: function ($row, index) { - return $row.find('td').eq(index + (this.hasSelectors ? 1 : 0)); + return $row.children(':nth(' + (index + (this.hasSelectors ? 1 : 0)) + ')'); }, /** * Returns the current number of columns. The editable renderer may add a @@ -196,42 +192,6 @@ ListRenderer.include({ return n; }, /** - * Determine if a given cell is readonly. A cell is considered readonly if - * the field is readonly, or the widget is statically readonly (like the - * sequence widget) of if there are a 'readonly' attr, with a matching - * domain. - * - * @param {Object} record a basic model record - * @param {Object} field a field description - * @param {Object} node a node object (from the arch) - * @returns {boolean} - */ - _isReadonly: function (record, field, node) { - if (field.readonly) { - return true; - } - var Widget = this.state.fieldsInfo.list[node.attrs.name].Widget; - if (Widget && Widget.prototype.readonly) { - return true; - } - if ('readonly' in node.modifiers) { - var fieldValues = record.getEvalContext(); - return new Domain(node.modifiers.readonly).compute(fieldValues); - } - return false; - }, - /** - * A cell is editable if its field isn't invisible and if it isn't readonly. - * - * @param {Object} record a basic model record - * @param {Object} field a field description - * @param {Object} node a node object (from the arch) - * @returns {boolean} - */ - _isEditable: function (record, field, node) { - return !this._isInvisible(record, node) && !this._isReadonly(record, field, node); - }, - /** * The renderer needs to support reordering lines. This is only active in * edit mode. The hasHandle attribute is used when there is a sequence * widget. @@ -260,65 +220,17 @@ ListRenderer.include({ * @param {Object} record * @param {Object} node * @param {integer} index + * @param {Object} [options] * @returns {jQueryElement} */ - _renderBodyCell: function (record, node, index) { + _renderBodyCell: function (record, node, index, options) { + var $cell = this._super.apply(this, arguments); if (this.mode === 'readonly') { - return this._super.apply(this, arguments); - } - var $cell = this._super(record, node); - if (node.tag === 'field') { - var field = this.state.fields[node.attrs.name]; - if (field.required) { - $cell.addClass('o_required_field'); - } - if (this._isReadonly(record, field, node)) { - $cell.addClass('o_readonly'); - } + return $cell; } return $cell.data('index', index); }, /** - * Helper method, to instantiate and render a field widget. It is only - * called by _selectCell, when editing a row. It kind of looks like the - * _renderBodyCell method of ListRenderer, but it has to properly tag the - * widget and to do other subtly different tasks, so it is awkward to try to - * merge them. - * - * @private - * @param {jQueryElement} $row - * @param {Object} node - * @param {integer} rowIndex - * @param {integer} colIndex - * @returns {AbstractField} the instance of the desired widget - */ - _renderFieldWidget: function ($row, node, rowIndex, colIndex) { - if (node.tag === 'button') { - return; - } - var name = node.attrs.name; - var record = this.state.data[rowIndex]; - var field = this.state.fields[name]; - if (!this._isEditable(record, field, node)) { - return; - } - var $td = this._findTd($row, colIndex); - var Widget = this.state.fieldsInfo.list[name].Widget; - var widget = new Widget(this, name, record, { - mode: 'edit', - viewType: 'list', - }); - if (widget.replace_element) { - $td.empty(); - } - $td.addClass('o_edit_mode'); - widget.appendTo($td); - widget.__rowIndex = rowIndex; - widget.__colIndex = colIndex; - this.widgets.push(widget); - return widget; - }, - /** * Editable rows are extended with their index, and possibly a trash icon on * their right, to allow deleting the corresponding record. * @@ -362,7 +274,7 @@ ListRenderer.include({ /** * This is one of the trickiest method in the editable renderer. It has to * do a lot of stuff: it has to determine which cell should be selected (if - * the terget cell is readonly, we need to find another suitable cell), then + * the target cell is readonly, we need to find another suitable cell), then * unselect the current row, and activate the line where the selected cell * is, if necessary. * @@ -370,118 +282,97 @@ ListRenderer.include({ * @param {integer} colIndex */ _selectCell: function (rowIndex, colIndex) { - var self = this; - - // do nothing if user tries to select current cell + // Do nothing if the user tries to select current cell if (rowIndex === this.currentRow && colIndex === this.currentCol) { return; } - // make sure the colIndex is on an editable field. otherwise, find - // next editable field, or do nothing if no such field exists - var data = this.state.data; - var fields = this.state.fields; - var col, field, isEditable, record; - for (var i = 0; i < this.columns.length; i++) { - col = this.columns[colIndex]; - record = data[rowIndex]; - field = fields[col.attrs.name]; - isEditable = this._isEditable(record, field, col); - if (isEditable) { - break; - } - colIndex = (colIndex + 1) % this.columns.length; - } - if (!isEditable) { - return; - } - - // if we are just changing active cell in the same row, activate the - // corresponding widget and return - if (rowIndex === this.currentRow) { - var w = _.findWhere(this.widgets, { - __rowIndex: rowIndex, - __colIndex: colIndex - }); - this.currentCol = colIndex; - w.activate(); - return; - } + var record = this.state.data[rowIndex]; - // if this is a different column, then we need to prepare for it by - // unselecting the current row. This will trigger the save. + // If this is a different row, then we need to prepare for it by + // unselecting the current row and selecting the new one. + var def = $.when(); if (rowIndex !== this.currentRow) { - this._unselectRow(); + def = this._unselectRow(); // This will trigger the save + def.then(this._selectRow.bind(this, rowIndex)); } - // instantiate column widgets - var $row = this.$('tbody tr').eq(rowIndex); - $row.addClass('o_selected_row'); - // force the width of each cell to its current value so that it won't + // In all cases (new row or not), activate the proper column widget + var self = this; + def.then(function () { + var fieldIndex = self._activateFieldWidget(record, colIndex - getNbButtonBefore(colIndex)); + if (fieldIndex < 0) { + return self._unselectRow(); + } + + self.currentCol = fieldIndex + getNbButtonBefore(fieldIndex); + + function getNbButtonBefore(index) { + var nbButtons = 0; + for (var i = 0 ; i < index ; i++) { + if (self.columns[i].tag === 'button') { + nbButtons++; + } + } + return nbButtons; + } + }); + }, + /** + * Activates the row at the given row index. + * + * @param {integer} rowIndex + */ + _selectRow: function (rowIndex) { + this.currentRow = rowIndex; + + var self = this; + var record = this.state.data[rowIndex]; + + // Force the width of each cell to its current value so that it won't // change if its content changes, to prevent the view from flickering - $row.find('td').each(function () { + var $row = this.$('tbody > tr:nth(' + rowIndex + ')'); + $row.addClass('o_selected_row'); + $row.children().each(function () { var $td = $(this); $td.width($td.width()); }); + // Instantiate column widgets and destroy potential readonly ones + var oldWidgets = _.clone(this.allFieldWidgets[record.id]); _.each(this.columns, function (node, index) { - var widget = self._renderFieldWidget($row, node, rowIndex, index); - if (widget && index === colIndex) { - widget.activate(); + var $newTd = self._renderBodyCell(record, node, index, { + renderInvisible: true, + renderWidgets: true, + }); + var $td = self._findTd($row, index); + if ($td.hasClass('o_list_button')) { + self._unregisterModifiersElement(node, record, $td.children()); // FIXME this is ugly... } + $td.empty().append($newTd.contents()); }); + _.each(oldWidgets, this._destroyFieldWidget.bind(this, record)); - this.currentRow = rowIndex; - this.currentCol = colIndex; this.trigger_up('change_mode', {mode: 'edit'}); }, /** * Set the value of a cell. This method can be called when the value of a - * cell was modified, for example afte an onchange. + * cell was modified, for example after an onchange. * * @param {integer} rowIndex * @param {integer} colIndex * @param {boolean} keepDirtyFlag - * @param {boolean} forceDirtyFlag */ - _setCellValue: function (rowIndex, colIndex, keepDirtyFlag, forceDirtyFlag) { + _setCellValue: function (rowIndex, colIndex, keepDirtyFlag) { var record = this.state.data[rowIndex]; var node = this.columns[colIndex]; - var $td = this._findTd(this.$('tbody tr').eq(rowIndex), colIndex); - var $new_td = this._renderBodyCell(record, node, colIndex); - if (keepDirtyFlag && $td.hasClass('o_field_dirty') || forceDirtyFlag) { - $new_td.addClass('o_field_dirty'); + var $oldTd = this._findTd(this.$('tbody > tr:nth(' + rowIndex + ')'), colIndex); + var $newTd = this._renderBodyCell(record, node, colIndex, {mode: 'readonly'}); + if (keepDirtyFlag && $oldTd.hasClass('o_field_dirty')) { + $newTd.addClass('o_field_dirty'); } - $td.replaceWith($new_td); - }, - /** - * Updates the row of a particular record of the editable list view. - * - * @param {index} index the index of the record whose row needs updating - * @param {string[]} changedFields names of the fields which changed - * @param {OdooEvent} event the event that triggered the change - */ - _updateRow: function (index, changedFields, event) { - var self = this; - var record = this.state.data[index]; - - // filter out fields that are not present in the view. They may come - // from an onchange, but we need to ignore them. - changedFields = _.filter(changedFields, function (fieldName) { - return _.findWhere(self.columns, {name: fieldName}); - }); - _.each(changedFields, function (field_name) { - var widget = _.findWhere(self.widgets, {name: field_name}); - if (widget && widget.__rowIndex === index) { - widget.reset(record, event); - widget.$el.parent().addClass('o_field_dirty'); - } else { - var column_index = _.findIndex(self.columns, function (column) { - return column.attrs.name === field_name; - }); - self._setCellValue(index, column_index, true, true); - } - }); + this._unregisterModifiersElement(node, record, $oldTd); + $oldTd.replaceWith($newTd); }, /** * This method is called whenever we click/move outside of a row that was @@ -491,79 +382,76 @@ ListRenderer.include({ * If the current row is valid, then it can be unselected. If it is not * valid, we need to check if it is dirty. If it is not dirty, it can be * unselected (and will be removed). If it is dirty, we need to ask the user - * for confirmation. If the user agrees, the row will be removed, otherwise + * for confirmation. If the user agrees, the row will be removed, otherwise * nothing happens (it is still selected) * * @returns {Deferred} The deferred resolves if the row was unselected (and - * possibly removed). If may be rejected, when the row is dirty and the + * possibly removed). If may be rejected, when the row is dirty and the * user refuses to discard its changes. */ _unselectRow: function () { - var self = this; - - // We need to protect against calling this method when no row is - // selected. + // Protect against calling this method when no row is selected if (this.currentRow === null) { return $.when(); } - var discarded = false; - - function discardLine() { - self.trigger_up('discard_line', {id: currentLineID}); - discarded = true; - def.resolve(); + var self = this; + var record = this.state.data[this.currentRow]; + var rowWidgets = _.clone(this.allFieldWidgets[record.id]); + var isRowValid = _.every(rowWidgets, this._canWidgetBeSaved.bind(this)); + var $row = this.$('tbody > tr.o_selected_row'); + + // If the row is not valid and is dirty, we have to ask the user + // confirmation before going further + var def = $.when(); + if (!isRowValid && this.dirtyRows[record.id]) { + def = $.Deferred(); + var message = _t("The line has been modified, your changes will be discarded. Are you sure you want to discard the changes ?"); + var dialog = Dialog.confirm(this, message, { + title: _t("Warning"), + confirm_callback: def.resolve.bind(def), + cancel_callback: def.reject.bind(def), + }); + dialog.$modal.on('hidden.bs.modal', def.reject.bind(def)); } - var rowWidgets = _.where(this.widgets, {__rowIndex: this.currentRow}); - var isRowValid = _.every(rowWidgets, function (w) { return w.isValid();}); - var $row = this.$('tbody tr.o_selected_row'); - - var def; - - if (isRowValid) { - // trigger the save (if the record isn't dirty, the datamodel won't save - // anything) - self.trigger_up('field_changed', { - dataPointID: self.state.data[self.currentRow].id, - changes: {}, - force_save: true, - }); - self.trigger_up('change_mode', {mode: 'readonly'}); - $row.removeClass('o_selected_row'); - $row.find('td').removeClass('o_edit_mode'); - def = $.when(); - } else { - def = $.Deferred(); - var currentLineID = this.state.data[this.currentRow].id; - var isDirty = this.dirtyRows[currentLineID]; - if (isDirty) { - // if the currently selected record is dirty, we want to ask for - // confirmation before discarding it - var message = _t("The line has been modified, your changes will be discarded. Are you sure you want to discard the changes ?"); - var options = { - title: _t("Warning"), - confirm_callback: discardLine, - cancel_callback: def.reject.bind(def) - }; - var dialog = Dialog.confirm(this, message, options); - dialog.$modal.on('hidden.bs.modal', def.reject.bind(def)); + return def.then(function finalizeUnselect() { + var rowRemoved = false; + var keepDirtyFlag = true; + if (isRowValid) { + // Trigger the save (if the record isn't dirty, the datamodel + // won't save anything) + self.trigger_up('field_changed', { + dataPointID: record.id, + changes: {}, + force_save: true, + }); + } else if (record.res_id) { + // Not a creation, tell the model to discard the changes + self.trigger_up('discard_changes', {id: record.id}); + keepDirtyFlag = false; } else { - discardLine(); + // Creation, tell the model to discard the row and remove the + // associated row DOM element + self.trigger_up('discard_line', {id: record.id}); + $row.remove(); // FIXME row indexes are not updated... let's remove data('index') and check index dynamically ? + rowRemoved = true; } - } - return def.then(function finalizeUnselect() { - var widgets = _.where(self.widgets, {__rowIndex: self.currentRow}); - _.each(widgets, function (widget) { - self._setCellValue(self.currentRow, widget.__colIndex, true); - widget.destroy(); - }); - if (discarded) { - $row.remove(); + // Update row if it was not removed + if (!rowRemoved) { + $row.removeClass('o_selected_row'); + _.each(self.columns, function (col, colIndex) { + self._setCellValue(self.currentRow, colIndex, keepDirtyFlag); + }); } - self.widgets = _.without(self.widgets, widgets); + + // Destroy the edition widgets + _.each(rowWidgets, self._destroyFieldWidget.bind(self, record)); + + delete self.dirtyRows[record.id]; self.currentRow = null; + self.trigger_up('change_mode', {mode: 'readonly'}); }); }, @@ -628,21 +516,8 @@ ListRenderer.include({ this._unselectRow(); }, /** - * We react to field_changed events by adding the .o_field_dirty css class, - * which changes its style to a visible orange outline. It allows the user - * to have a visual feedback on which fields changed, and on which fields - * have been saved. - * - * @param {OdooEvent} event - */ - _onFieldChanged: function (event) { - this.dirtyRows[event.data.dataPointID] = true; - var $td = event.target.$el.parent(); - $td.addClass('o_field_dirty'); - }, - /** * Clicking on a footer should unselect (and save) the currently selected - * row. It has to be done this way, because this is a click inside this.el, + * row. It has to be done this way, because this is a click inside this.el, * and _onWindowClicked ignore those clicks. */ _onFooterClick: function () { @@ -669,13 +544,13 @@ ListRenderer.include({ * cell on the right, or the first on the next line. This method is called * when the user press the TAB key. * - * @param {OdooEvent} event + * @param {OdooEvent} ev */ - _onMoveNext: function (event) { + _onMoveNext: function (ev) { // we need to stop the event, to prevent interference with some other // component up there, such as a form renderer. - event.stopPropagation(); - if (this.currentCol < this.columns.length - 1) { + ev.stopPropagation(); + if (this.currentCol + 1 < this.columns.length) { this._selectCell(this.currentRow, this.currentCol + 1); } else if (this.currentRow < this.state.data.length - 1) { this._selectCell(this.currentRow + 1, 0); @@ -691,10 +566,27 @@ ListRenderer.include({ } }, /** + * Move the cursor on the previous available cell, so either the previous + * available cell on the left, or the last on the previous line. This method + * is called when the user press the TAB key while holding the shift key. + * + * @param {OdooEvent} ev + */ + _onMovePrevious: function (ev) { + // we need to stop the event, to prevent interference with some other + // component up there, such as a form renderer. + ev.stopPropagation(); + if (this.currentCol > 0) { + this._selectCell(this.currentRow, this.currentCol - 1); + } else if (this.currentRow > 0) { + this._selectCell(this.currentRow - 1, this.columns.length - 1); + } + }, + /** * Move the cursor one cell right, if possible */ _onMoveRight: function () { - if (this.currentCol < this.columns.length - 1) { + if (this.currentCol + 1 < this.columns.length) { this._selectCell(this.currentRow, this.currentCol + 1); } }, @@ -770,7 +662,6 @@ ListRenderer.include({ this._unselectRow(); }, - }); }); 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 c51cae980da..b987929bce0 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -4,7 +4,6 @@ odoo.define('web.ListRenderer', function (require) { var BasicRenderer = require('web.BasicRenderer'); var config = require('web.config'); var core = require('web.core'); -var Domain = require('web.Domain'); var field_utils = require('web.field_utils'); var Pager = require('web.Pager'); var session = require('web.session'); @@ -51,19 +50,11 @@ var ListRenderer = BasicRenderer.extend({ this._super.apply(this, arguments); var self = this; this.hasHandle = false; - this._processModifiers(); this.columns = _.reject(this.arch.children, function (c) { - if (c.attrs.invisible === '1') { - return true; - } - if (c.modifiers.tree_invisible) { - return true; - } if (c.attrs.widget === 'handle') { self.hasHandle = true; } - c.name = c.attrs.name; - return false; + return !!JSON.parse(c.attrs.modifiers || "{}").tree_invisible; }); this.rowDecorations = _.chain(this.arch.attrs) .pick(function (value, key) { @@ -213,22 +204,6 @@ var ListRenderer = BasicRenderer.extend({ return this.hasSelectors ? n+1 : n; }, /** - * Determine if a given cell is invisible. A cell is considered invisible - * if there is an 'invisible' attr, with a matching domain. - * - * @private - * @param {Object} record a basic model record - * @param {Object} node a node object (from the arch) - * @returns {boolean} - */ - _isInvisible: function (record, node) { - if ('invisible' in node.modifiers) { - var fieldValues = record.getEvalContext(); - return new Domain(node.modifiers.invisible).compute(fieldValues); - } - return false; - }, - /** * Render a list of <td>, with aggregates if available. It can be displayed * in the footer, or for each open groups. * @@ -267,56 +242,88 @@ var ListRenderer = BasicRenderer.extend({ return $('<tbody>').append($rows); }, /** - * Render a cell for the table. For most cells, we only want to display the - * formatted value, with some appropriate css class. However, when the + * Render a cell for the table. For most cells, we only want to display the + * formatted value, with some appropriate css class. However, when the * node was explicitely defined with a 'widget' attribute, then we * instantiate the corresponding widget. * * @private * @param {Object} record * @param {Object} node + * @param {integer} colIndex + * @param {Object} [options] + * @param {Object} [options.mode] + * @param {Object} [options.renderInvisible=false] + * force the rendering of invisible cell content + * @param {Object} [options.renderWidgets=false] + * force the rendering of the cell value thanks to a widget * @returns {jQueryElement} a <td> element */ - _renderBodyCell: function (record, node) { - var self = this; - var $td = $('<td>'); - if (this._isInvisible(record, node)) { - return $td; + _renderBodyCell: function (record, node, colIndex, options) { + var tdClassName; + if (node.tag === 'button') { + tdClassName = 'o_list_button'; + } else if (node.attrs.widget){ + tdClassName = ('o_' + node.attrs.widget + '_cell'); + } else { + tdClassName = FIELD_CLASSES[this.state.fields[node.attrs.name].type]; } - var name = node.attrs.name; - if (node.attrs.widget) { - var Widget = this.state.fieldsInfo.list[name].Widget; - var widget = new Widget(this, name, record, { - mode: 'readonly', - viewType: 'list', - }); - widget.appendTo($td); - $td.addClass('o_' + node.attrs.widget + '_cell'); + var $td = $('<td>', { class: tdClassName }); + + // We register modifiers on the <td> element so that it gets the correct + // modifiers classes (for styling) + var modifiers = this._registerModifiers(node, record, $td); + // If the invisible modifiers is true, the <td> element is left empty. + // Indeed, if the modifiers was to change the whole cell would be + // rerendered anyway. + if (modifiers.invisible && !(options && options.renderInvisible)) { return $td; } + if (node.tag === 'button') { - var $button = $('<button type="button">').addClass('o_icon_button'); - $button.append($('<i>').addClass('fa').addClass(node.attrs.icon)) - .prop('title', node.attrs.string) - .click(function (e) { - e.stopPropagation(); - self.trigger_up('button_clicked', { - attrs: node.attrs, - record: record, - }); - }); - $td.append($button); - $td.addClass('o_list_button').click(function (e) { - // prevent opening or editing the record on cell click - e.stopPropagation(); - }); - return $td; + return $td.append(this._renderButton(record, node)); } + if (node.attrs.widget || (options && options.renderWidgets)) { + this.defs = []; // TODO maybe wait for those somewhere ? + var widget = this._renderFieldWidget(node, record, _.pick(options, 'mode')); + delete this.defs; + return $td.append(widget.$el); + } + var name = node.attrs.name; var field = this.state.fields[name]; var value = record.data[name]; - $td.addClass(FIELD_CLASSES[field.type]); - var formatted_value = field_utils.format[field.type](value, field, { data: record.data }); - return $td.html(formatted_value); + var formattedValue = field_utils.format[field.type](value, field, { data: record.data }); + return $td.html(formattedValue); + }, + /** + * Renders the button element associated to the given node and record. + * + * @private + * @param {Object} record + * @param {Object} node + * @returns {jQuery} a <button> element + */ + _renderButton: function (record, node) { + var $button = $('<button>', { + type: 'button', + class: 'o_icon_button', + title: node.attrs.string, + }); + $button.append($('<i>', {class: 'fa ' + node.attrs.icon})); + + this._registerModifiers(node, record, $button); + + // TODO this should be moved to a handler + var self = this; + $button.on("click", function (e) { + e.stopPropagation(); + self.trigger_up('button_clicked', { + attrs: node.attrs, + record: record, + }); + }); + + return $button; }, /** * Render a complete empty row. This is used to fill in the blanks when we @@ -544,7 +551,10 @@ var ListRenderer = BasicRenderer.extend({ */ _renderRow: function (record) { var decorations = this._computeDecorationClassNames(record); - var $cells = _.map(this.columns, this._renderBodyCell.bind(this, record)); + var self = this; + var $cells = _.map(this.columns, function (node, index) { + return self._renderBodyCell(record, node, index, {mode: 'readonly'}); + }); var $tr = $('<tr class="o_data_row">') .data('id', record.id) .addClass(decorations.length && decorations.join(' ')) @@ -623,17 +633,6 @@ var ListRenderer = BasicRenderer.extend({ return this._super(); }, /** - * Process the modifiers by setting their parsed version on the - * corresponding nodes. - * - * @private - */ - _processModifiers: function () { - _.each(this.arch.children, function (c) { - c.modifiers = JSON.parse(c.attrs.modifiers || '{}'); - }); - }, - /** * Whenever we change the state of the selected rows, we need to call this * method to keep the this.selection variable in sync, and also to recompute * the aggregates. @@ -713,5 +712,4 @@ var ListRenderer = BasicRenderer.extend({ }); return ListRenderer; - }); diff --git a/addons/web/static/src/less/new_views.less b/addons/web/static/src/less/new_views.less index cb43686fbeb..f8c0ca2e7b6 100644 --- a/addons/web/static/src/less/new_views.less +++ b/addons/web/static/src/less/new_views.less @@ -5,63 +5,44 @@ .o_list_view { tr.o_selected_row { outline: 1px solid lightgray; - } - td.o_edit_mode { - position: relative; - outline: 1px solid gray; - &.o_required_field { - outline-width: 2px; - z-index: 1; - } - background-color: white; + > td:not(.o_readonly):not(.o_list_button) { + position: relative; + background-color: white; - .o_form_input { - border: none; - padding: 0 5px; - } + > .o_field_widget, > .o_form_field_many2one { + .o-position-absolute(0, 0, 0, 0); + } - > .o_field_widget, > .o_form_field_many2one { - .o-position-absolute(0, 0, 0, 0); - } + .o_form_field_many2one .o_form_input_dropdown { + .o-flex(1, 1, auto); - .o_form_field_many2one .o_form_input_dropdown { - .o-flex(1, 1, auto); + .o_form_input { + height: 100%; + } - .o_form_input { - height: 100%; + .o_dropdown_button { + display: none; + } } - .o_dropdown_button { - display: none; + .o_form_field_many2manytags { + .o_form_field_many2one { + height: 100%; + display: inline-block; + } } - } - .o_form_field_many2manytags { - .o_form_field_many2one { - height: 100%; - display: inline-block; + .o_field_boolean { + .o-flex-display(); + .o-align-items(center); } } - - .o_field_boolean { - .o-flex-display(); - .o-align-items(center); - } } td.o_field_dirty { outline: 1px solid orange; } - // - // &.o_readonly { - // outline: 2px solid lightgray; - // } - // div.o_field_dirty { - // outline: 1px solid orange; - // } - // } - } //////////////////////////////////////////////////////////////////////////////// |
