summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src')
-rw-r--r--addons/web/static/src/js/chrome/view_manager.js4
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js12
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js166
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js18
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js214
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js46
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js44
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js29
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js236
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js58
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js11
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js6
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js127
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js306
-rw-r--r--addons/web/static/src/js/views/list/list_view.js6
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js2
16 files changed, 715 insertions, 570 deletions
diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js
index d96cb711f32..e15df8f0acc 100644
--- a/addons/web/static/src/js/chrome/view_manager.js
+++ b/addons/web/static/src/js/chrome/view_manager.js
@@ -183,7 +183,9 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
core.bus.on('clear_uncommitted_changes', self, function(chain_callbacks) {
chain_callbacks(function() {
if (self.active_view.controller) {
- return self.active_view.controller.canBeDiscarded();
+ return self.active_view.controller.discardChanges(undefined, {
+ readonlyIfRealDiscard: true,
+ });
}
});
});
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js
index 87b3bf3f512..757e917dd36 100644
--- a/addons/web/static/src/js/fields/abstract_field.js
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -192,7 +192,7 @@ var AbstractField = Widget.extend({
*
* @abstract
*/
- commitChanges: function () {
+ commitChanges: function () { // TODO maybe should be done for all fields (keyboard save)
},
/**
* Returns the main field's DOM element (jQuery form) which can be focused
@@ -217,16 +217,10 @@ var AbstractField = Widget.extend({
* value was changed by the user. This is checked before saving a record, by
* the view.
*
- * 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
+ * Note: 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
+ * @returns {boolean} true/false if the widget is valid
*/
isValid: function () {
return this._isValid;
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 6a7ea1869b7..2a3d5e01125 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -517,11 +517,13 @@ var KanbanFieldMany2One = AbstractField.extend({
var FieldX2Many = AbstractField.extend({
tagName: 'div',
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
+ add_record: '_onAddRecord',
+ edit_line: '_onEditLine',
field_changed: '_onFieldChanged',
kanban_record_delete: '_onDeleteRecord',
list_record_delete: '_onDeleteRecord',
- add_record: '_onAddRecord',
open_record: '_onOpenRecord',
+ save_line: '_onSaveLine',
toggle_column_order: '_onToggleColumnOrder',
}),
@@ -563,31 +565,29 @@ var FieldX2Many = AbstractField.extend({
//--------------------------------------------------------------------------
/**
+ * A x2m field can only be saved if it finished the edition of all its rows.
+ * On parent view saving, we have to ask the x2m fields to commit their
+ * changes, that is confirming the save of the in-edition row or asking the
+ * user if he wants to discard it if necessary.
+ *
* @override
+ * @returns {Deferred}
*/
- isSet: function () {
- return true;
+ commitChanges: function () {
+ var inEditionRecordID =
+ this.renderer
+ && this.renderer.viewType === "list"
+ && this.renderer.getEditableRecordID();
+ if (inEditionRecordID) {
+ return this._saveLine(inEditionRecordID);
+ }
+ return this._super.apply(this, arguments);
},
/**
- * This is a tricky override. For relational fields, we don't just need to
- * know if the field is valid. We also need to discard some invalid lines,
- * if the user is ok with it (or automatically if the invalid line is not
- * dirty). This is the reason why we need to change the signature of the
- * AbstractField isValid method to optionally return a deferred.
- *
* @override
- * @returns {boolean|Deferred} if there is a renderer (sub list or kanban
- * view), we may need to ask the user => it will return a deferred.
- * Otherwise, it returns a boolean
*/
- isValid: function () {
- if (!this.renderer) {
- return this._super.apply(this, arguments);
- }
- var def = $.Deferred();
- this.renderer.canBeSaved()
- .then(def.resolve.bind(def, true), def.resolve.bind(def, false));
- return def;
+ isSet: function () {
+ return true;
},
//--------------------------------------------------------------------------
@@ -687,6 +687,39 @@ var FieldX2Many = AbstractField.extend({
this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this));
}
},
+ /**
+ * Saves the line associated to the given recordID. If the line is valid,
+ * it only has to be switched to readonly mode as all the line changes have
+ * already been notified to the model so that they can be saved in db if the
+ * parent view is actually saved. If the line is not valid, the line is to
+ * be discarded if the user agrees (this behavior is not a list editable
+ * one but a x2m one as it is made to replace the "discard" button which
+ * exists for list editable views).
+ *
+ * @private
+ * @param {string} recordID
+ * @returns {Deferred} resolved if the line was properly saved or discarded.
+ * rejected if the line could not be saved and the user
+ * did not agree to discard.
+ */
+ _saveLine: function (recordID) {
+ var self = this;
+ var def = $.Deferred();
+ this.renderer.commitChanges(recordID).then(function () { // TODO wrong as no mutex protection
+ var fieldNames = self.renderer.canBeSaved(recordID);
+ if (fieldNames.length) {
+ self.trigger_up('discard_x2m_changes', {
+ recordID: recordID,
+ onSuccess: def.resolve.bind(def),
+ onFailure: def.reject.bind(def),
+ });
+ } else {
+ self.renderer.setRowMode(recordID, 'readonly', true);
+ def.resolve();
+ }
+ });
+ return def;
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -708,33 +741,44 @@ var FieldX2Many = AbstractField.extend({
* by the parent controller.
*
* @private
- * @param {OdooEvent}
+ * @param {OdooEvent} ev
*/
- _onDeleteRecord: function (event) {
- event.stopPropagation();
+ _onDeleteRecord: function (ev) {
+ ev.stopPropagation();
this._setValue({
operation: 'REMOVE',
- ids: [event.data.id],
+ ids: [ev.data.id],
});
},
/**
+ * Called when the renderer asks to edit a line, in that case simply tells
+ * him back to toggle the mode of this row.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEditLine: function (ev) {
+ ev.stopPropagation();
+ this.renderer.setRowMode(ev.data.recordID, 'edit');
+ },
+ /**
* Updates the given record with the changes.
*
* @private
- * @param {OdooEvent}
+ * @param {OdooEvent} ev
*/
- _onFieldChanged: function (event) {
- if (event.target === this) {
+ _onFieldChanged: function (ev) {
+ if (ev.target === this) {
return;
}
- event.stopPropagation();
+ ev.stopPropagation();
// changes occured in an editable list
- var changes = event.data.changes;
+ var changes = ev.data.changes;
if (Object.keys(changes).length) {
this._setValue({
operation: 'UPDATE',
- id: event.data.dataPointID,
- data: changes
+ id: ev.data.dataPointID,
+ data: changes,
});
}
},
@@ -748,14 +792,30 @@ var FieldX2Many = AbstractField.extend({
// to implement
},
/**
+ * Called when the renderer ask to save a line (the user tries to leave it)
+ * -> Nothing is to "save" here, the model was already notified of the line
+ * changes; if the row could be saved, we make the row readonly. Otherwise,
+ * we trigger a new event for the view to tell it to discard the changes
+ * made to that row.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSaveLine: function (ev) {
+ ev.stopPropagation();
+ this._saveLine(ev.data.recordID)
+ .done(ev.data.onSuccess)
+ .fail(ev.data.onFailure);
+ },
+ /**
* Adds field name information to the event, so that the view upstream is
* aware of which widgets it has to redraw.
*
* @private
- * @param {OdooEvent}
+ * @param {OdooEvent} ev
*/
- _onToggleColumnOrder: function (event) {
- event.data.field = this.name;
+ _onToggleColumnOrder: function (ev) {
+ ev.data.field = this.name;
},
});
@@ -769,13 +829,13 @@ var FieldOne2Many = FieldX2Many.extend({
/**
* @override
* @param {Object} record
- * @param {OdooEvent} [event] an event that triggered the reset action
+ * @param {OdooEvent} [ev] an event that triggered the reset action
* @returns {Deferred}
*/
- reset: function (record, event) {
+ reset: function (record, ev) {
var self = this;
- if (event && event.target === this && self.view.arch.tag === 'tree' && this.editable) {
- var command = event.data.changes[this.name];
+ if (ev && ev.target === this && ev.data.changes && self.view.arch.tag === 'tree' && this.editable) {
+ var command = ev.data.changes[this.name];
if (command.operation === 'UPDATE') {
var state = record.data[this.name];
var fieldNames = state.getFieldNames();
@@ -784,8 +844,8 @@ var FieldOne2Many = FieldX2Many.extend({
}
}
return this._super.apply(this, arguments).then(function () {
- if (event && event.target === self && self.view.arch.tag === 'tree') {
- if (event.data.changes[self.name].operation === 'CREATE') {
+ if (ev && ev.target === self && ev.data.changes && self.view.arch.tag === 'tree') {
+ if (ev.data.changes[self.name].operation === 'CREATE') {
var index = self.editable === 'top' ? 0 : self.value.data.length - 1;
var newID = self.value.data[index].id;
self.renderer.editRecord(newID);
@@ -833,13 +893,13 @@ var FieldOne2Many = FieldX2Many.extend({
*
* @override
* @private
- * @param {OdooEvent|MouseEvent} event this event comes either from the 'Add
+ * @param {OdooEvent|MouseEvent} ev this event comes either from the 'Add
* record' link in the list editable renderer, or from the 'Create' button
* in the kanban view
*/
- _onAddRecord: function (event) {
+ _onAddRecord: function (ev) {
// we don't want interference with the components upstream.
- event.stopPropagation();
+ ev.stopPropagation();
if (this.editable) {
this._setValue({
@@ -863,14 +923,14 @@ var FieldOne2Many = FieldX2Many.extend({
* form view.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onOpenRecord: function (event) {
+ _onOpenRecord: function (ev) {
// we don't want interference with the components upstream.
- event.stopPropagation();
+ ev.stopPropagation();
this._openFormDialog({
- id: event.data.id,
+ id: ev.data.id,
on_saved: this._setValue.bind(this, { operation: 'NOOP' }),
readonly: this.mode === 'readonly',
});
@@ -932,14 +992,14 @@ var FieldMany2Many = FieldX2Many.extend({
* to the form view.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onOpenRecord: function (event) {
- _.extend(event.data, {
+ _onOpenRecord: function (ev) {
+ _.extend(ev.data, {
context: this.record.getContext(this.recordParams),
domain: this.record.getDomain(this.recordParams),
fields_view: this.attrs.views && this.attrs.views.form,
- on_saved: this.trigger_up.bind(this, 'reload', {db_id: event.data.id}),
+ on_saved: this.trigger_up.bind(this, 'reload', {db_id: ev.data.id}),
readonly: this.mode === 'readonly',
string: this.string,
});
@@ -1297,9 +1357,9 @@ var FieldMany2ManyTags = AbstractField.extend({
* @param {KeyboardEvent} event
*/
_onKeyDown: function (event) {
- if($(event.target).val() === "" && event.which === $.ui.keyCode.BACKSPACE) {
+ if ($(event.target).val() === "" && event.which === $.ui.keyCode.BACKSPACE) {
var $badges = this.$('.badge');
- if($badges.length) {
+ if ($badges.length) {
this._removeTag($badges.last().data('id'));
}
}
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index df44309636b..1dacce59fa6 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -67,19 +67,20 @@ var AbstractController = Widget.extend({
//--------------------------------------------------------------------------
/**
- * Determines if we can discard the current state. For example, when the
- * user open the 'home' screen, the view manager will call this method on
- * the active view to make sure it is ok to open the home screen (and
- * potentially loose all current state).
+ * Discards the changes made on the record associated to the given ID, or
+ * all changes made by the current controller if no recordID is given. For
+ * example, when the user open the 'home' screen, the view manager will call
+ * this method on the active view to make sure it is ok to open the home
+ * screen (and lose all current state).
*
* Note that it returns a deferred, because the view could choose to ask the
* user if he agrees to discard.
*
- * @override
- * @returns {Deferred} If the deferred is resolved, we assume the changes
- * can be discarded. If it is rejected, then we cannot discard.
+ * @param {string} [recordID]
+ * if not given, we consider all the changes made by the controller
+ * @returns {Deferred} resolved if properly discarded, rejected otherwise
*/
- canBeDiscarded: function () {
+ discardChanges: function (recordID) {
return $.when();
},
/**
@@ -296,4 +297,3 @@ var AbstractController = Widget.extend({
return AbstractController;
});
-
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
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 287bd800576..ceedbb2df6e 100644
--- a/addons/web/static/src/js/views/field_manager_mixin.js
+++ b/addons/web/static/src/js/views/field_manager_mixin.js
@@ -12,8 +12,6 @@ var BasicModel = require('web.BasicModel');
var FieldManagerMixin = {
custom_events: {
- discard_changes: '_onDiscardChanges',
- discard_line: '_onDiscardLine',
field_changed: '_onFieldChanged',
load: '_onLoad',
},
@@ -88,33 +86,6 @@ 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,
- * then clicks somewhere else. The new line need to be discarded, but we
- * don't want to trigger a real notifyChanges (no need for that, and also,
- * we don't want to rerender the UI).
- *
- * @param {OdooEvent} event
- */
- _onDiscardLine: function (event) {
- this.model.removeLine(event.data.id);
- },
- /**
* This is the main job of the FMM: deciding what to do when a controlled
* field changes. Most of the time, it notifies the model that a change
* just occurred, then confirm the change.
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 2cf90dcf3db..e3b129c7915 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -15,6 +15,7 @@ var FormController = BasicController.extend({
open_one2many_record: '_onOpenOne2ManyRecord',
bounce_edit: '_onBounceEdit',
button_clicked: '_onButtonClicked',
+ discard_x2m_changes: '_onDiscardX2MChanges',
open_record: '_onOpenRecord',
toggle_column_order: '_onToggleColumnOrder',
}),
@@ -29,7 +30,6 @@ var FormController = BasicController.extend({
this.defaultButtons = params.defaultButtons;
this.hasSidebar = params.hasSidebar;
this.toolbar = params.toolbar;
- this.mode = params.mode;
},
//--------------------------------------------------------------------------
@@ -45,45 +45,9 @@ var FormController = BasicController.extend({
autofocus: function () {
},
/**
- * Determines if we can discard the current change. 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
- * @returns {Deferred} If the deferred is resolved, we assume the changes
- * can be discarded. If it is rejected, then we cannot discard.
- */
- canBeDiscarded: function () {
- var self = this;
- if (!this.isDirty) {
- return $.when();
- }
- var message = _t("The record has been modified, your changes will be discarded. Are you sure you want to leave this page ?");
- var def = $.Deferred();
- var options = {
- title: _t("Warning"),
- confirm_callback: function () {
- this.on('closed', null, function () {
- self.isDirty = false;
- def.resolve();
- });
- },
- 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;
- },
- /**
- * @returns {Deferred}
- */
- canBeSaved: function () {
- return this.renderer.canBeSaved()
- .fail(this._notifyInvalidFields.bind(this));
- },
- /**
* This method switches the form view in edit mode, with a new record.
*
+ * @todo make record creation a basic controller feature
* @returns {Deferred}
*/
createRecord: function () {
@@ -100,7 +64,7 @@ var FormController = BasicController.extend({
}).then(function (handle) {
self.handle = handle;
self._updateEnv();
- self._toEditMode();
+ self._setMode('edit');
});
},
/**
@@ -143,10 +107,10 @@ var FormController = BasicController.extend({
this.$buttons.append($footer);
} else {
this.$buttons.append(qweb.render("FormView.buttons", {widget: this}));
- this.$buttons.on('click', '.o_form_button_edit', this._toEditMode.bind(this));
- this.$buttons.on('click', '.o_form_button_save', this.saveRecord.bind(this));
- this.$buttons.on('click', '.o_form_button_cancel', this._onDiscardChange.bind(this));
- this.$buttons.on('click', '.o_form_button_create', this.createRecord.bind(this));
+ this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this));
+ this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this));
+ this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this));
+ this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this));
this._updateButtons();
}
@@ -199,27 +163,6 @@ var FormController = BasicController.extend({
this._updateSidebar();
}
},
- /**
- * Save the current record (@see _saveRecord)
- *
- * @param {Object} options
- * @returns {Deferred}
- */
- saveRecord: function (options) {
- return this.mutex.exec(this._saveRecord.bind(this, options));
- },
- /**
- * We need to check for all 'mode' change, because the information is not
- * available from the model.
- *
- * @override
- * @param {Object} params
- * @returns {Deferred}
- */
- update: function (params) {
- this.mode = params.mode || this.mode;
- return this._super.apply(this, arguments);
- },
//--------------------------------------------------------------------------
// Private
@@ -234,7 +177,6 @@ var FormController = BasicController.extend({
* @param {string} id
*/
_confirmSave: function (id) {
- this.isDirty = false;
if (id === this.handle) {
return this.reload();
} else {
@@ -250,24 +192,6 @@ var FormController = BasicController.extend({
}
},
/**
- * Helper function to display a warning that some field 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 (field_name) {
- var fieldStr = fields[field_name].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.
*
* @override
@@ -295,71 +219,6 @@ var FormController = BasicController.extend({
this._super(state);
},
/**
- * Save the record
- *
- * @param {Object} options
- * @param {boolean} [options.stayInEdit=false] if true, don't switch to
- * readonly mode after saving the record
- * @param {boolean} [options.reload=true] if true, reload the record after
- * saving
- * @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 (options) {
- var self = this;
- options = 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.renderer.commitChanges();
-
- var stayInEdit = 'stayInEdit' in options ? options.stayInEdit : false;
- var shouldReload = 'reload' in options ? options.reload : true;
- if (!this.model.isDirty(this.handle) && !this.model.isNew(this.handle)) {
- if (!stayInEdit) {
- this._toReadOnlyMode();
- }
- return $.Deferred().resolve();
- } else {
- return self.canBeSaved().then(function () {
- return self.model.save(self.handle, {
- reload: shouldReload,
- savePoint: options.savePoint
- })
- .then(function () {
- if (!stayInEdit) {
- self._toReadOnlyMode();
- }
- self.isDirty = false;
- });
- });
- }
- },
- /**
- * Change the view mode to 'edit'
- *
- * @see _toReadOnlyMode
- * @private
- */
- _toEditMode: function () {
- this.$el.addClass('o_form_editable');
- this.update({mode: "edit"}, {reload: false});
- },
- /**
- * Change the view mode to 'readonly'
- *
- * @see _toEditMode
- * @private
- */
- _toReadOnlyMode: function () {
- if (this.mode !== 'readonly') {
- this.$el.removeClass('o_form_editable');
- this.update({mode: "readonly"}, {reload: false});
- }
- },
- /**
* Updates the controller's title according to the new state
*
* @override
@@ -434,7 +293,10 @@ var FormController = BasicController.extend({
def = this._callButtonAction(attrs, event.data.record);
} else {
// save the record but don't switch to readonly mode
- def = this.saveRecord({stayInEdit: true, reload: false}).then(function () {
+ def = this.saveRecord(this.handle, {
+ stayInEdit: true,
+ reload: false,
+ }).then(function () {
// we need to reget the record to make sure we have changes made
// by the basic model, such as the new res_id, if the record is
// new.
@@ -453,22 +315,47 @@ var FormController = BasicController.extend({
}
},
/**
- * If the user clicks on 'Discard', we have to check if the changes can be
- * discarded, then actually do it.
+ * Called when the user wants to create a new record -> @see createRecord
+ *
+ * @private
+ */
+ _onCreate: function () {
+ this.createRecord();
+ },
+ /**
+ * Called when the user wants to discard the changes made to the current
+ * record -> @see discardChanges
+ *
+ * @private
+ */
+ _onDiscard: function () {
+ this.discardChanges();
+ },
+ /**
+ * Called when a x2m asks to discard the changes made to one of its row.
+ *
+ * @todo find a better way to handle this... this could also be used outside
+ * of form views
*
* @private
+ * @param {OdooEvent} ev
*/
- _onDiscardChange: function () {
+ _onDiscardX2MChanges: function (ev) {
var self = this;
- this.canBeDiscarded().then(function () {
- self.model.discardChanges(self.handle);
- self.isDirty = false;
- if (!self.model.isNew(self.handle)) {
- self._toReadOnlyMode();
- } else {
- self.trigger_up('switch_to_previous_view');
- }
- });
+ ev.stopPropagation();
+ var recordID = ev.data.recordID;
+ this.discardChanges(recordID)
+ .done(function () {
+ if (self.model.isNew(recordID)) {
+ self._abandonRecord(recordID);
+ }
+ // TODO this will tell the renderer to rerender the widget that
+ // asked for the discard but will unfortunately lose the click
+ // made on another row if any
+ self._confirmChange(self.handle, [ev.target.name], ev)
+ .always(ev.data.onSuccess);
+ })
+ .fail(ev.data.onFailure);
},
/**
* Called when the user clicks on 'Duplicate Record' in the sidebar
@@ -479,26 +366,18 @@ var FormController = BasicController.extend({
var self = this;
this.model.duplicateRecord(this.handle)
.then(function (handle) {
- self.isDirty = false;
self.handle = handle;
self._updateEnv();
- self._toEditMode();
+ self._setMode('edit');
});
},
/**
- * This method comes from the field manager mixin. We force to save directly
- * the changes if the form is in readonly, because in that case the changes
- * come from widgets that are editable even in readonly (e.g. Priority).
+ * Called when the user wants to edit the current record -> @see _setMode
*
- * @override
* @private
- * @param {OdooEvent} event
*/
- _onFieldChanged: function (event) {
- if (this.mode === 'readonly') {
- event.data.force_save = true;
- }
- this._super.apply(this, arguments);
+ _onEdit: function () {
+ this._setMode('edit');
},
/**
* Opens a one2many record (potentially new) in a dialog. This handler is
@@ -555,6 +434,16 @@ var FormController = BasicController.extend({
}).open();
},
/**
+ * Called when the user wants to save the current record -> @see saveRecord
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSave: function (ev) {
+ ev.stopPropagation(); // Prevent x2m lines to be auto-saved
+ this.saveRecord();
+ },
+ /**
* This method is called when someone tries to sort a column, most likely
* in a x2many list view
*
@@ -567,7 +456,6 @@ var FormController = BasicController.extend({
var state = this.model.get(this.handle);
this.renderer.confirmChange(state, state.id, [field]);
},
-
});
return FormController;
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 1e40ce13dd6..75c2b987a8f 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -27,54 +27,30 @@ var FormRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
/**
- * This method has two responsabilities: find every invalid fields in the
- * current form, 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 there is a label.
+ * Extend the method so that labels also receive the 'o_form_invalid' class
+ * if necessary.
*
- * @returns {Deferred}
- * if it fails, it gives the list of invalid field names
+ * @override
+ * @see BasicRenderer.canBeSaved
+ * @param {string} recordID
+ * @returns {string[]}
*/
- canBeSaved: function () {
+ canBeSaved: function (recordID) {
var self = this;
- var invalidFields = [];
- var defs = [];
+ var fieldNames = this._super.apply(this, arguments);
- function markWidget(widget, isValid) {
- if (!isValid) {
- invalidFields.push(widget.name);
- }
- widget.$el.toggleClass('o_form_invalid', !isValid);
- var idForLabel = self.idsForLabels[widget.name];
- var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
- $label.toggleClass('o_form_invalid', !isValid);
- }
+ var $labels = this.$('label');
+ $labels.removeClass('o_form_invalid');
- _.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);
- }));
- } else {
- markWidget(widget, isValid);
+ _.each(fieldNames, function (fieldName) {
+ var idForLabel = self.idsForLabels[fieldName];
+ if (idForLabel) {
+ $labels
+ .filter('[for=' + idForLabel + ']')
+ .addClass('o_form_invalid');
}
});
- return $.when.apply($, defs).then(function () {
- if (invalidFields.length) {
- return $.Deferred().reject(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').
- */
- commitChanges: function () { // TODO add a test
- _.each(this.allFieldWidgets, function (recordWidgets) {
- _.invoke(recordWidgets, 'commitChanges');
- });
+ return fieldNames;
},
/**
* returns the active tab pages for each notebook
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index b3eb314b612..9bb728011e8 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -219,17 +219,6 @@ var KanbanController = BasicController.extend({
});
},
/**
- * This method comes from the field manager mixin. Since there is no
- * onchange for kanban, we force a save for every change
- *
- * @override
- * @param {OdooEvent} event
- */
- _onFieldChanged: function (event) {
- event.data.force_save = true;
- this._super.apply(this, arguments);
- },
- /**
* @param {OdooRevent} event
*/
_onLoadMore: function (event) {
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 5c976bba903..8152ce4987b 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -113,12 +113,6 @@ var KanbanRenderer = BasicRenderer.extend({
this.widgets[0].addQuickCreate();
},
/**
- * @returns {Deferred}
- */
- canBeSaved: function () {
- return $.when();
- },
- /**
* Removes a widget (record if ungrouped, column if grouped) from the view.
*
* @param {Widget} widget the instance of the widget to remove
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 88887f45fc3..50f47a73fc0 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -20,7 +20,8 @@ var ListController = BasicController.extend({
custom_events: _.extend({}, BasicController.prototype.custom_events, {
add_record: '_onAddRecord',
button_clicked: '_onButtonClicked',
- change_mode: '_onChangeMode',
+ edit_line: '_onEditLine',
+ save_line: '_onSaveLine',
selection_changed: '_onSelectionChanged',
toggle_column_order: '_onToggleColumnOrder',
toggle_group: '_onToggleGroup',
@@ -49,6 +50,24 @@ var ListController = BasicController.extend({
//--------------------------------------------------------------------------
/**
+ * To improve performance, list view must not be rerendered if it is asked
+ * to discard all its changes. Indeed, only the in-edition row needs to be
+ * discarded in that case.
+ *
+ * @override
+ * @param {string} [recordID] - default to main recordID
+ * @returns {Deferred}
+ */
+ discardChanges: function (recordID) {
+ if ((recordID || this.handle) === this.handle) {
+ recordID = this.renderer.getEditableRecordID();
+ if (recordID === null) {
+ return $.when();
+ }
+ }
+ return this._super(recordID);
+ },
+ /**
* Calculate the active domain of the list view. This should be done only
* if the header checkbox has been checked. This is done by evaluating the
* search results, and then adding the dataset domain (i.e. action domain).
@@ -93,14 +112,17 @@ var ListController = BasicController.extend({
/**
* Display and bind all buttons in the control panel
*
+ * Note: clicking on the "Save" button does nothing special. Indeed, all
+ * editable rows are saved once left and clicking on the "Save" button does
+ * induce the leaving of the current row.
+ *
* @override
- * @param {jQuery Node} $node
+ * @param {jQuery} $node
*/
renderButtons: function ($node) {
if (!this.noLeaf && this.hasButtons) {
this.$buttons = $(qweb.render('ListView.buttons', {widget: this}));
this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this));
- this.$buttons.on('click', '.o_list_button_save', this._onSave.bind(this));
this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this));
this.$buttons.appendTo($node);
}
@@ -151,8 +173,25 @@ var ListController = BasicController.extend({
//--------------------------------------------------------------------------
/**
+ * @see BasicController._abandonRecord
+ * If the given abandoned record is not the main one, notifies the renderer
+ * to remove the appropriate subrecord (line).
+ *
+ * @override
+ * @private
+ * @param {string} [recordID] - default to the main recordID
+ */
+ _abandonRecord: function (recordID) {
+ this._super.apply(this, arguments);
+ if ((recordID || this.handle) !== this.handle) {
+ var state = this.model.get(this.handle);
+ this.renderer.removeLine(state, recordID);
+ }
+ },
+ /**
* Add a record to the list
*
+ * @todo make record creation a basic controller feature
* @private
*/
_addRecord: function () {
@@ -194,6 +233,21 @@ var ListController = BasicController.extend({
return this.renderer.confirmSave(state, id);
},
/**
+ * Allows to change the mode of a single row.
+ *
+ * @override
+ * @private
+ * @param {string} mode
+ * @param {string} [recordID] - default to main recordID
+ */
+ _setMode: function (mode, recordID) {
+ this._super.apply(this, arguments);
+ if ((recordID || this.handle) !== this.handle) {
+ this.renderer.setRowMode(recordID, mode);
+ this._updateButtons(mode);
+ }
+ },
+ /**
* Display the sidebar (the 'action' menu in the control panel) if we have
* some selected records.
*/
@@ -249,17 +303,6 @@ var ListController = BasicController.extend({
this._callButtonAction(event.data.attrs, event.data.record);
},
/**
- * This event is triggered when a list renderer goes from mode = readonly to
- * edit, and vice versa. In that case, we need to make sure that the buttons
- * displayed in the control panel are correct.
- *
- * @private
- * @param {OdooEvent} event
- */
- _onChangeMode: function (event) {
- this._updateButtons(event.data.mode);
- },
- /**
* When the user clicks on the 'create' button, two things can happen. We
* can switch to the form view with no active res_id, so it is in 'create'
* mode, or we can edit inline.
@@ -290,53 +333,41 @@ var ListController = BasicController.extend({
/**
* Handler called when the user clicked on the 'Discard' button.
*
- * @private
- * @param {MouseEvent} event
+ * @param {Event} ev
*/
- _onDiscard: function (event) {
- event.stopPropagation();
- this.model.discardChanges(this.handle);
- this._updateButtons('readonly');
- this.update(this.handle, {reload: false});
+ _onDiscard: function (ev) {
+ ev.stopPropagation(); // So that it is not considered as a row leaving
+ this.discardChanges();
},
/**
- * Opens the Export Dialog
+ * Called when the user asks to edit a row -> Updates the controller buttons
*
- * @private
+ * @param {OdooEvent} ev
*/
- _onExportData: function () {
- var record = this.model.get(this.handle);
- new DataExport(this, record).open();
+ _onEditLine: function (ev) {
+ ev.stopPropagation();
+ this._setMode('edit', ev.data.recordID);
},
/**
- * This method comes from the field manager mixin.
- * @todo: there is a very similar method in kanban and form controllers.
- * This should be moved in basic controller, and shared between basic views.
+ * Opens the Export Dialog
*
- * @override
* @private
- * @param {OdooEvent} event
*/
- _onFieldChanged: function (event) {
- if (this.renderer.mode === 'readonly') {
- event.data.force_save = true;
- }
- this._super.apply(this, arguments);
+ _onExportData: function () {
+ var record = this.model.get(this.handle);
+ new DataExport(this, record).open();
},
/**
- * changes of the list editable are automatically saved when unselecting the
- * row, which is done when clicking on 'Save' (anywhere outside the row
- * actually), so this function should only switch back to readonly mode
+ * Called when the renderer displays an editable row and the user tries to
+ * leave it -> Saves the record associated to that line.
*
- * @private
- * @param {MouseEvent} event
+ * @param {OdooEvent} ev
*/
- _onSave: function (event) {
- // we prevent the event propagation because we don't want this event to
- // trigger a click on the main bus, which would be then caught by the
- // list editable renderer and would unselect the newly created row
- // event.stopPropagation();
- this._updateButtons('readonly');
+ _onSaveLine: function (ev) {
+ var recordID = ev.data.recordID;
+ this.saveRecord(recordID)
+ .done(ev.data.onSuccess)
+ .fail(ev.data.onFailure);
},
/**
* When the current selection changes (by clicking on the checkboxes on the
@@ -373,7 +404,7 @@ var ListController = BasicController.extend({
this.pager.updateState({current_min: 1});
}
this.model.setSort(data.id, event.data.name);
- this.update();
+ this.update({});
},
/**
* In a grouped list view, each group can be clicked on to open/close them.
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 b24ec6a0c29..54c85e9c888 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
@@ -12,7 +12,6 @@ odoo.define('web.EditableListRenderer', function (require) {
* view. It uses the same widgets, but the code is totally stand alone.
*/
var core = require('web.core');
-var Dialog = require('web.Dialog');
var ListRenderer = require('web.ListRenderer');
var _t = core._t;
@@ -53,10 +52,6 @@ ListRenderer.include({
this.currentRow = null;
this.currentCol = null;
-
- // the dirtyRows object is there to keep track of lines that have been
- // modified by the user
- this.dirtyRows = {};
},
/**
* @override
@@ -75,37 +70,24 @@ ListRenderer.include({
//--------------------------------------------------------------------------
/**
- * @returns {Deferred}
+ * If the given recordID is the list main one (or that no recordID is
+ * given), then the whole view can be saved if one of the two following
+ * conditions is true:
+ * - There is no line in edition (all lines are saved so they are all valid)
+ * - The line in edition can be saved
+ *
+ * @override
+ * @param {string} [recordID]
+ * @returns {string[]}
*/
- canBeSaved: function () {
- var self = this;
- var invalidDirtyWidgets = [];
- var invalidCleanWidgets = [];
- _.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
- // input, resolve and reject deferred
- }
- if (invalidCleanWidgets.length) {
- this._unselectRow();
+ canBeSaved: function (recordID) {
+ if ((recordID || this.state.id) === this.state.id) {
+ recordID = this.getEditableRecordID();
+ if (recordID === null) {
+ return [];
+ }
}
- return $.when();
+ return this._super(recordID);
},
/**
* @see BasicRenderer.confirmChange
@@ -115,6 +97,8 @@ ListRenderer.include({
* to have a visual feedback on which fields changed, and on which fields
* have been saved.
*
+ * @todo maybe should be a basic behavior
+ *
* @override
* @param {Object} state - a record resource (the new full state)
* @param {string} id - the local id for the changed record
@@ -123,8 +107,6 @@ ListRenderer.include({
* @returns {Deferred}
*/
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();
@@ -144,10 +126,7 @@ ListRenderer.include({
*/
confirmSave: function (state, savedRecordID) {
this.state = state;
- var index = _.findIndex(this.state.data, {id: savedRecordID});
- for (var j = 0; j < this.columns.length; j++) {
- this._setCellValue(index, j, false);
- }
+ this.setRowMode(savedRecordID, 'readonly');
},
/**
* Edit a given record in the list
@@ -155,10 +134,86 @@ ListRenderer.include({
* @param {string} recordID
*/
editRecord: function (recordID) {
- var index = _.findIndex(this.state.data, function (record) {
- return record.id === recordID;
- });
- this._selectCell(index, 0);
+ var rowIndex = _.findIndex(this.state.data, {id: recordID});
+ this._selectCell(rowIndex, 0);
+ },
+ /**
+ * Returns the recordID associated to the line which is currently in edition
+ * or null if there is no line in edition.
+ *
+ * @returns {string|null}
+ */
+ getEditableRecordID: function () {
+ if (this.currentRow !== null) {
+ return this.state.data[this.currentRow].id;
+ }
+ return null;
+ },
+ /**
+ * Removes the line associated to the given recordID (the index of the row
+ * is found thanks to the old state), then updates the state.
+ *
+ * @param {Object} state
+ * @param {string} recordID
+ */
+ removeLine: function (state, recordID) {
+ var rowIndex = _.findIndex(this.state.data, {id: recordID});
+ if (rowIndex === this.currentRow) {
+ this.currentRow = null;
+ }
+ var $row = this.$('tbody > tr:nth(' + rowIndex + ')');
+ $row.remove(); // FIXME row indexes are not updated... let's remove data('index') and check index dynamically ?
+
+ this.state = state;
+ },
+ /**
+ * Updates the already rendered row associated to the given recordID so that
+ * it fits the given mode.
+ *
+ * @param {string} recordID
+ * @param {string} mode
+ * @param {boolean} [keepDirtyFlag=false]
+ */
+ setRowMode: function (recordID, mode, keepDirtyFlag) {
+ var self = this;
+ var rowIndex = _.findIndex(this.state.data, {id: recordID});
+ if (rowIndex < 0) {
+ return;
+ }
+ var editMode = (mode === 'edit');
+ var record = this.state.data[rowIndex];
+
+ this.currentRow = editMode ? rowIndex : null;
+ var $row = this.$('tbody > tr:nth(' + rowIndex + ')');
+ $row.toggleClass('o_selected_row', editMode);
+
+ if (editMode) {
+ // Instantiate column widgets and destroy potential readonly ones
+ var oldWidgets = _.clone(this.allFieldWidgets[record.id]);
+ _.each(this.columns, function (node, colIndex) {
+ var $td = self._findTd($row, colIndex);
+ var $newTd = self._renderBodyCell(record, node, colIndex, {
+ renderInvisible: true,
+ renderWidgets: true,
+ });
+
+ // TODO this is ugly...
+ if ($td.hasClass('o_list_button')) {
+ self._unregisterModifiersElement(node, record, $td.children());
+ }
+
+ // 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
+ $td.width($td.width());
+ $td.empty().append($newTd.contents());
+ });
+ _.each(oldWidgets, this._destroyFieldWidget.bind(this, record));
+ } else {
+ for (var colIndex = 0; colIndex < this.columns.length; colIndex++) {
+ this._setCellValue(rowIndex, colIndex, keepDirtyFlag || false);
+ }
+ }
},
//--------------------------------------------------------------------------
@@ -192,6 +247,15 @@ ListRenderer.include({
return n;
},
/**
+ * @override
+ * @returns {Deferred}
+ */
+ _render: function () {
+ this.currentRow = null;
+ this.currentCol = null;
+ return this._super.apply(this, arguments);
+ },
+ /**
* 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.
@@ -296,20 +360,13 @@ ListRenderer.include({
return;
}
- var record = this.state.data[rowIndex];
-
- // 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) {
- def = this._unselectRow(); // This will trigger the save
- def.then(this._selectRow.bind(this, rowIndex));
- }
-
- // In all cases (new row or not), activate the proper column widget
+ // Select the row then activate the widget in the correct cell
var self = this;
- def.then(function () {
+ this._selectRow(rowIndex).then(function () {
+ var record = self.state.data[rowIndex];
var fieldIndex = self._activateFieldWidget(record, colIndex - getNbButtonBefore(colIndex));
+
+ // If no widget to activate in the row, unselect the row
if (fieldIndex < 0) {
return self._unselectRow();
}
@@ -333,36 +390,20 @@ ListRenderer.include({
* @param {integer} rowIndex
*/
_selectRow: function (rowIndex) {
- this.currentRow = rowIndex;
+ // Do nothing if already selected
+ if (rowIndex === this.currentRow) {
+ return $.when();
+ }
+ // To select a row, the currently selected one must be unselected first
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
- 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 $newTd = self._renderBodyCell(record, node, index, {
- renderInvisible: true,
- renderWidgets: true,
+ return this._unselectRow().then(function () {
+ // Notify the controller we want to make a record editable
+ var record = self.state.data[rowIndex];
+ self.trigger_up('edit_line', {
+ recordID: record.id,
});
- 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.trigger_up('change_mode', {mode: 'edit'});
},
/**
* Set the value of a cell. This method can be called when the value of a
@@ -375,6 +416,7 @@ ListRenderer.include({
_setCellValue: function (rowIndex, colIndex, keepDirtyFlag) {
var record = this.state.data[rowIndex];
var node = this.columns[colIndex];
+
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')) {
@@ -382,17 +424,24 @@ ListRenderer.include({
}
this._unregisterModifiersElement(node, record, $oldTd);
$oldTd.replaceWith($newTd);
+
+ // Destroy old cell field widget if any
+ // TODO this is very inefficient O(n^3) instead of O(n) on row update
+ // because this method is called for each cell (O(n)), each time we have
+ // to find the associated record widget (O(n)) and once found, the
+ // search is performed again by the _destroyFieldWidget function
+ if (node.tag === 'field') {
+ var recordWidgets = this.allFieldWidgets[record.id];
+ var w = _.findWhere(recordWidgets, {name: node.attrs.name}); //
+ if (w) {
+ this._destroyFieldWidget(record, w);
+ }
+ }
},
/**
* This method is called whenever we click/move outside of a row that was
* in edit mode. This is the moment we save all accumulated changes on that
- * row, if needed.
- *
- * 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
- * nothing happens (it is still selected)
+ * row, if needed (@see BasicController.saveRecord).
*
* @returns {Deferred} The deferred resolves if the row was unselected (and
* possibly removed). If may be rejected, when the row is dirty and the
@@ -404,64 +453,14 @@ ListRenderer.include({
return $.when();
}
- 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));
- }
-
- 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 {
- // 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;
- }
-
- // 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);
- });
- }
-
- // 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'});
+ var def = $.Deferred();
+ this.trigger_up('save_line', {
+ recordID: record.id,
+ onSuccess: def.resolve.bind(def),
+ onFailure: def.reject.bind(def),
});
+ return def;
},
//--------------------------------------------------------------------------
@@ -483,9 +482,10 @@ ListRenderer.include({
event.stopPropagation();
// but we do want to unselect current row
- this._unselectRow();
-
- this.trigger_up('add_record');
+ var self = this;
+ this._unselectRow().then(function () {
+ self.trigger_up('add_record'); // TODO write a test, the deferred was not considered
+ });
},
/**
* When the user clicks on a cell, we simply select it.
@@ -640,7 +640,7 @@ ListRenderer.include({
return;
}
- // there is currenctly no selected row
+ // there is currently no selected row
if (this.currentRow === null) {
return;
}
@@ -652,10 +652,10 @@ ListRenderer.include({
// ignore clicks in modals, except if the list is in a modal, and the
// click is performed in that modal
- var $listModal = this.$el.closest('.modal');
- if ($listModal.length) {
- var $click_modal = $(event.target).parents('.modal');
- if ($click_modal.prop('id') === $listModal.prop('id')) {
+ var $clickModal = $(event.target).closest('.modal');
+ if ($clickModal.length) {
+ var $listModal = this.$el.closest('.modal');
+ if ($clickModal.prop('id') !== $listModal.prop('id')) {
return;
}
}
diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js
index 1260a8bc69d..7b597c712bc 100644
--- a/addons/web/static/src/js/views/list/list_view.js
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -37,16 +37,18 @@ var ListView = BasicView.extend({
this._super.apply(this, arguments);
var arch = viewInfo.arch;
+ var mode = arch.attrs.editable && !params.readonly ? "edit" : "readonly";
+
this.controllerParams.editable = arch.attrs.editable;
this.controllerParams.hasSidebar = params.sidebar;
this.controllerParams.activeActions.delete = true;
this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
+ this.controllerParams.mode = mode;
this.rendererParams.arch = arch;
this.rendererParams.hasSelectors =
'hasSelectors' in params ? params.hasSelectors : true;
- this.rendererParams.mode =
- arch.attrs.editable && !params.readonly ? "edit" : "readonly";
+ this.rendererParams.mode = mode;
this.loadParams.limit = this.loadParams.limit || 80;
this.loadParams.type = 'list';
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index 6f0fe8328c3..d0c1fa75fdf 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -212,7 +212,7 @@ var FormViewDialog = ViewDialog.extend({
}
def = this.options.on_save(this.form_view.model.get(this.form_view.handle));
} else {
- def = this.form_view.saveRecord({
+ def = this.form_view.saveRecord(this.form_view.handle, {
stayInEdit: true,
reload: false,
savePoint: this.shouldSaveLocally,