diff options
Diffstat (limited to 'addons/web/static/src/js/views/basic')
3 files changed, 271 insertions, 33 deletions
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 97e0ca0f4be..33f6727987e 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -16,7 +16,7 @@ var Pager = require('web.Pager'); var _t = core._t; -return AbstractController.extend(FieldManagerMixin, { +var BasicController = AbstractController.extend(FieldManagerMixin, { custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, { reload: '_onReload', }), @@ -34,7 +34,7 @@ return AbstractController.extend(FieldManagerMixin, { this.hasButtons = params.hasButtons; FieldManagerMixin.init.call(this, this.model); this.handle = params.initialState.id; - this.isDirty = false; + this.mode = params.mode || 'readonly'; this.mutex = new concurrency.Mutex(); }, /** @@ -51,6 +51,82 @@ return AbstractController.extend(FieldManagerMixin, { //-------------------------------------------------------------------------- /** + * Determines if we can discard the current changes. If the model is not + * dirty, that is not a problem. However, if it is dirty, we have to ask + * the user for confirmation. + * + * @override + * @param {string} [recordID] - default to main recordID + * @returns {Deferred<boolean>} + * resolved if can be discarded, a boolean value is given to tells + * if there is something to discard or not + * rejected otherwise + */ + canBeDiscarded: function (recordID) { + if (!this.model.isDirty(recordID || this.handle)) { + return $.when(false); + } + + var message = _t("The record has been modified, your changes will be discarded. Are you sure you want to ?"); + var def = $.Deferred(); + var dialog = Dialog.confirm(this, message, { + title: _t("Warning"), + confirm_callback: def.resolve.bind(def, true), + cancel_callback: def.reject.bind(def), + }); + dialog.on('closed', def, def.reject); + return def; + }, + /** + * Ask the renderer if all associated field widget are in a valid state for + * saving (valid value and non-empty value for required fields). If this is + * not the case, this notifies the user with a warning containing the names + * of the invalid fields. + * + * Note: changing the style of invalid fields is the renderer's job. + * + * @param {string} [recordID] - default to main recordID + * @return {boolean} + */ + canBeSaved: function (recordID) { + var fieldNames = this.renderer.canBeSaved(recordID || this.handle); + if (fieldNames.length) { + this._notifyInvalidFields(fieldNames); + return false; + } + return true; + }, + /** + * Discards the changes made to the record whose ID is given, if necessary. + * Automatically leaves to default mode for the given record. + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.readonlyIfRealDiscard=false] + * After discarding record changes, the usual option is to make the + * record readonly. However, the view manager calls this function + * at inappropriate times in the current code and in that case, we + * don't want to go back to readonly if there is nothing to discard + * (e.g. when switching record in edit mode in form view, we expect + * the new record to be in edit mode too, but the view manager calls + * this function as the URL changes...) @todo get rid of this when + * the view manager is improved. + * @returns {Deferred} + */ + discardChanges: function (recordID, options) { + var self = this; + recordID = recordID || this.handle; + return this.canBeDiscarded(recordID).then(function (needDiscard) { + if (needDiscard) { // Just some optimization + self.model.discardChanges(recordID); + } + if (options && options.readonlyIfRealDiscard && !needDiscard) { + return; + } + self._setMode('readonly', recordID); + }); + }, + /** * @override */ renderPager: function ($node, options) { @@ -74,11 +150,32 @@ return AbstractController.extend(FieldManagerMixin, { this._updatePager(); // to force proper visibility }, /** + * Saves the record whose ID is given if necessary (@see _saveRecord). + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @returns {Deferred} + */ + saveRecord: function (recordID, options) { + // Some field widgets (e.g. 'html') can't detect (all) their changes, so + // we ask them to commit their current value before saving. This has to + // be done outside of the mutex protection of saving because + // commitChanges will trigger changes and these are also protected. So + // the actual saving has to be done after these changes. Also the + // commitChanges operation might not be synchronous for other reason + // (e.g. the x2m fields will ask the user if some discarding has to be + // made). This operation must also be mutex-protected as commitChanges + // function of x2m has to be aware of all final changes made to a row. + this.mutex.exec(this.renderer.commitChanges.bind(this.renderer, recordID || this.handle)); // TODO write a test for this + return this.mutex.exec(this._saveRecord.bind(this, recordID, options)); + }, + /** * @override * @returns {Deferred} */ update: function (params, options) { var self = this; + this.mode = params.mode || this.mode; return this._super(params, options).then(function () { self._updateEnv(); self._updatePager(); @@ -90,6 +187,24 @@ return AbstractController.extend(FieldManagerMixin, { //-------------------------------------------------------------------------- /** + * Does the necessary action when trying to "abandon" a given record (e.g. + * when trying to make a new record readonly without having saved it). By + * default, if the abandoned record is the main view one, the only possible + * action is to leave the current view. Otherwise, it is a x2m line, ask the + * model to remove it. + * + * @private + * @param {string} [recordID] - default to main recordID + */ + _abandonRecord: function (recordID) { + recordID = recordID || this.handle; + if (recordID === this.handle) { + this.trigger_up('switch_to_previous_view'); + } else { + this.model.removeLine(recordID); + } + }, + /** * We override applyChanges (from the field manager mixin) to protect it * with a mutex. * @@ -184,6 +299,24 @@ return AbstractController.extend(FieldManagerMixin, { return state.count !== 0; }, /** + * Helper function to display a warning that some fields have an invalid + * value. This is used when a save operation cannot be completed. + * + * @private + * @param {string[]} invalidFields - list of field names + */ + _notifyInvalidFields: function (invalidFields) { + var record = this.model.get(this.handle, {raw: true}); + var fields = record.fields; + var warnings = invalidFields.map(function (fieldName) { + var fieldStr = fields[fieldName].string; + return _.str.sprintf('<li>%s</li>', _.escape(fieldStr)); + }); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("The following fields are invalid:"), warnings.join('')); + }, + /** * Hook method, called when record(s) has been deleted. * * @see _deleteRecord @@ -193,6 +326,67 @@ return AbstractController.extend(FieldManagerMixin, { this.update({}); }, /** + * Saves the record whose ID is given, if necessary. Automatically leaves + * edit mode for the given record, unless told otherwise. + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.stayInEdit=false] + * if true, leave the record in edit mode after save + * @param {boolean} [options.reload=true] + * if true, reload the record after (real) save + * @param {boolean} [options.savePoint=false] + * if true, the record will only be 'locally' saved: its changes + * will move from the _changes key to the data key + * @returns {Deferred} + */ + _saveRecord: function (recordID, options) { + recordID = recordID || this.handle; + options = _.defaults(options || {}, { + stayInEdit: false, + reload: true, + savePoint: false, + }); + + var def = $.Deferred(); + // Check if the view is in a valid state for saving + // Note: it is the model's job to do nothing if there is nothing to save + if (this.canBeSaved(recordID)) { + def = this.model.save(recordID, { // Save then leave edit mode + reload: options.reload, + savePoint: options.savePoint, + }); + } else { + def.reject(); // Cannot be saved, do nothing at all + } + + if (options.stayInEdit) { + return def; + } else { + return def.then(this._setMode.bind(this, 'readonly', recordID)); + } + }, + /** + * Change the mode for the record associated to the given ID. + * If the given recordID is the view's main one, then the whole view mode is + * changed (@see BasicController.update). + * + * @private + * @param {string} mode - 'readonly' or 'edit' + * @param {string} [recordID] + */ + _setMode: function (mode, recordID) { + recordID = recordID || this.handle; + // If trying to make a temporary record readonly, discard the record + if (mode === 'readonly' && this.model.isNew(recordID)) { + this._abandonRecord(recordID); + return; + } + if (recordID === this.handle) { + this.update({mode: mode}, {reload: false}); + } + }, + /** * Helper method, to get the current environment variables from the model * and notifies the component chain (by bubbling an event up) */ @@ -226,15 +420,17 @@ return AbstractController.extend(FieldManagerMixin, { //-------------------------------------------------------------------------- /** - * We need to keep manually track of the isDirty property. Note that it is - * not the same as the one from the basic model. A new record from the - * basic model is considered dirty, but from the controller point of view, - * there was no user interaction and should not be dirty. + * Forces to save directly the changes if the controller is in readonly, + * because in that case the changes come from widgets that are editable even + * in readonly (e.g. Priority). * * @private + * @param {OdooEvent} */ - _onFieldChanged: function () { - this.isDirty = true; + _onFieldChanged: function (ev) { + if (this.mode === 'readonly') { + ev.data.force_save = true; + } FieldManagerMixin._onFieldChanged.apply(this, arguments); }, /** @@ -261,7 +457,7 @@ return AbstractController.extend(FieldManagerMixin, { this.reload({fieldNames: data.fieldNames}); } }, - }); +return BasicController; }); 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 ea8986f9da8..f3e69a6b453 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -219,7 +219,8 @@ var BasicModel = AbstractModel.extend({ discardChanges: function (id, options) { options = options || {}; var element = this.localData[id]; - var rollback = options.rollback || this.isNew(id); + var isNew = this.isNew(id); + var rollback = options.rollback || isNew; this._visitChildren(element, function (elem) { if (rollback && elem._savePoint) { if (elem._savePoint instanceof Array) { @@ -227,8 +228,10 @@ var BasicModel = AbstractModel.extend({ } else { elem._changes = _.extend({}, elem._savePoint); } + elem._isDirty = !isNew; } else { elem._changes = null; + elem._isDirty = false; } }); }, @@ -383,26 +386,26 @@ var BasicModel = AbstractModel.extend({ return element; }, /** - * return true if a record is dirty. A record is considered dirty if it has - * some unsaved changes. A list is considered dirty if its _changes key is - * set to an array of its new datapoints (possibly empty) + * Returns true if a record is dirty. A record is considered dirty if it has + * some unsaved changes, marked by the _isDirty property on the record or + * one of its subrecords. * - * @param {string} id id for a local resource + * @param {string} id - the local resource id * @returns {boolean} */ isDirty: function (id) { var isDirty = false; - var record = this.localData[id]; - this._visitChildren(record, function (r) { - if (r.type === "record" ? !_.isEmpty(r._changes) : r._changes) { + this._visitChildren(this.localData[id], function (r) { + if (r._isDirty) { isDirty = true; } }); return isDirty; }, /** - * Check if a record is new, meaning if it is in the process of being created - * and no actual record exists in db. + * Check if a localData is new, meaning if it is in the process of being + * created and no actual record exists in db. Note: if the localData is not + * of the "record" type, then it is always considered as not new. * * Note: A virtual id is a character string composed of an integer and has * a dash and other information. @@ -413,7 +416,11 @@ var BasicModel = AbstractModel.extend({ * @returns {boolean} */ isNew: function (id) { - var res_id = this.localData[id].res_id; + var data = this.localData[id]; + if (data.type !== "record") { + return false; + } + var res_id = data.res_id; if (typeof res_id === 'number') { return false; } else if (typeof res_id === 'string' && /^[0-9]+-/.test(res_id)) { @@ -707,15 +714,20 @@ var BasicModel = AbstractModel.extend({ record.res_ids.push(id); record.count++; } + + // Update the data directly or reload them + var def; if (shouldReload) { - // erase changes as they have been applied - record._changes = {}; - return self._fetchRecord(record); + def = self._fetchRecord(record); } else { _.extend(record.data, record._changes); - record._changes = {}; - return false; } + + // Erase changes as they have been applied + record._changes = {}; + record._isDirty = false; + + return def; }); } else { return $.when(record_id); @@ -837,6 +849,7 @@ var BasicModel = AbstractModel.extend({ var field; var defs = []; record._changes = record._changes || {}; + record._isDirty = true; // apply changes to local data for (var fieldName in changes) { @@ -874,6 +887,7 @@ var BasicModel = AbstractModel.extend({ _.each(fieldNames, function (name) { if (record._changes && record._changes[name] === record.data[name]) { delete record._changes[name]; + record._isDirty = !_.isEmpty(record._changes); } }); return self._fetchSpecialData(record).then(function (fieldNames2) { 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 dd0f59a4176..ed9726478a0 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -36,6 +36,41 @@ var BasicRenderer = AbstractRenderer.extend({ this.mode = params.mode || 'readonly'; }, /** + * This method has two responsabilities: find every invalid fields in the + * current view, and making sure that they are displayed as invalid, by + * toggling the o_form_invalid css class. It has to be done both on the + * widget, and on the label, if any. + * + * @param {string} recordID + * @returns {string[]} the list of invalid field names + */ + canBeSaved: function (recordID) { + var self = this; + var invalidFields = []; + _.each(this.allFieldWidgets[recordID], function (widget) { + var canBeSaved = self._canWidgetBeSaved(widget); + if (!canBeSaved) { + invalidFields.push(widget.name); + } + widget.$el.toggleClass('o_form_invalid', !canBeSaved); + }); + return invalidFields; + }, + /** + * Calls 'commitChanges' on all field widgets, so that they can notify the + * environment with their current value (useful for widgets that can't + * detect when their value changes, e.g. field 'html'). + * + * @param {string} recordID + * @return {Deferred} + */ + commitChanges: function (recordID) { + var defs = _.map(this.allFieldWidgets[recordID], function (widget) { + return widget.commitChanges(); + }); + return $.when.apply($, defs); + }, + /** * 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. @@ -222,14 +257,7 @@ var BasicRenderer = AbstractRenderer.extend({ */ _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; + return widget.isValid() && (widget.isSet() || !modifiers.required); }, /** * Updates the modifiers evaluation associated to a given modifiers data and |
