Skip to content
Snippets Groups Projects
Commit 870a9447 authored by Tim Otten's avatar Tim Otten
Browse files

Merge pull request #1251 from totten/master-bb-issaved

CRM-12934 - Multiple Backbone updates
parents 0824d2bb 4630e5b5
Branches
Tags
No related merge requests found
......@@ -8,7 +8,7 @@
* To load collections using API queries, set the "crmCriteria" property or override the
* method "toCrmCriteria".
*
* @param method
* @param method Accepts normal Backbone.sync methods; also accepts "crm-replace"
* @param model
* @param options
* @see tests/qunit/crm-backbone
......@@ -34,6 +34,13 @@
case 'read':
CRM.api(model.crmEntityName, 'get', model.toCrmCriteria(), apiOptions);
break;
// replace all entities matching "x.crmCriteria" with new entities in "x.models"
case 'crm-replace':
var params = this.toCrmCriteria();
params.version = 3;
params.values = this.toJSON();
CRM.api(model.crmEntityName, 'replace', params, apiOptions);
break;
default:
apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
break;
......@@ -113,10 +120,132 @@
};
/**
* Configure a model class to track whether a model has unsaved changes.
*
* Methods:
* - setModified() - flag the model as modified/dirty
* - isSaved() - return true if there have been no changes to the data since the last fetch or save
* Events:
* - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
*
* Note: You should not directly call isSaved() within the context of the success/error/sync callback;
* I haven't found a way to make isSaved() behave correctly within these callbacks without patching
* Backbone. Instead, attach an event listener to the 'saved' event.
*
* @param ModelClass
*/
CRM.Backbone.trackSaved = function(ModelClass) {
// Retain references to some of the original class's functions
var Parent = _.pick(ModelClass.prototype, 'initialize', 'save', 'fetch');
// Private callback
var onSyncSuccess = function() {
this._modified = false;
if (this._oldModified.length > 0) {
this._oldModified.pop();
}
this.trigger('saved', this, this.isSaved());
};
var onSaveError = function() {
if (this._oldModified.length > 0) {
this._modified = this._oldModified.pop();
this.trigger('saved', this, this.isSaved());
}
};
// Defaults - if specified in ModelClass, preserve
_.defaults(ModelClass.prototype, {
isSaved: function() {
var result = !this.isNew() && !this._modified;
return result;
},
_saved_onchange: function(model, options) {
if (options.parse) return;
this.setModified();
},
setModified: function() {
var oldModified = this._modified;
this._modified = true;
if (!oldModified) {
this.trigger('saved', this, this.isSaved());
}
}
});
// Overrides - if specified in ModelClass, replace
_.extend(ModelClass.prototype, {
initialize: function(options) {
this._modified = false;
this._oldModified = [];
this.listenTo(this, 'change', this._saved_onchange);
this.listenTo(this, 'error', onSaveError);
this.listenTo(this, 'sync', onSyncSuccess);
if (Parent.initialize) {
return Parent.initialize.apply(this, arguments);
}
},
save: function() {
// we'll assume success
this._oldModified.push(this._modified);
return Parent.save.apply(this, arguments);
},
fetch: function() {
this._oldModified.push(this._modified);
return Parent.fetch.apply(this, arguments);
}
});
};
/**
* Configure a model class to support client-side soft deletion.
* One can call "model.setDeleted(BOOLEAN)" to flag an entity for
* deletion (or not) -- however, deletion will be deferred until save()
* is called.
*
* Methods:
* setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
* isSoftDeleted() - determine whether model has been soft-deleted
* Events:
* softDelete(model, is_deleted) -- change value of is_deleted
*
* @param ModelClass
*/
CRM.Backbone.trackSoftDelete = function(ModelClass) {
// Retain references to some of the original class's functions
var Parent = _.pick(ModelClass.prototype, 'save');
// Defaults - if specified in ModelClass, preserve
_.defaults(ModelClass.prototype, {
is_soft_deleted: false,
setSoftDeleted: function(is_deleted) {
if (this.is_soft_deleted != is_deleted) {
this.is_soft_deleted = is_deleted;
this.trigger('softDelete', this, is_deleted);
if (this.setModified) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
}
},
isSoftDeleted: function() {
return this.is_soft_deleted;
}
});
// Overrides - if specified in ModelClass, replace
_.extend(ModelClass.prototype, {
save: function(attributes, options) {
if (this.isSoftDeleted()) {
return this.destroy(options);
} else {
return Parent.save.apply(this, arguments);
}
}
});
};
/**
* Connect a "collection" class to CiviCRM's APIv3
*
* Note: the collection supports a special property, crmCriteria, which is an array of
* query options to send to the API
* query options to send to the API.
*
* @code
* // Setup class
......@@ -132,6 +261,9 @@
* crmCriteria: {contact_type: 'Organization'}
* });
* c.fetch();
* c.get(123).set('property', 'value');
* c.get(456).setDeleted(true);
* c.save();
* @endcode
*
* @param Class CollectionClass
......@@ -143,7 +275,30 @@
_.defaults(CollectionClass.prototype, {
crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
toCrmCriteria: function() {
return this.crmCriteria || {};
return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
},
/**
* Reconcile the server's collection with the client's collection.
* New/modified items from the client will be saved/updated on the
* server. Deleted items from the client will be deleted on the
* server.
*
* @param Object options - accepts "success" and "error" callbacks
*/
save: function(options) {
options || (options = {});
var collection = this;
var success = options.success;
options.success = function(resp) {
// Ensure attributes are restored during synchronous saves.
collection.reset(resp, options);
if (success) success(collection, resp, options);
// collection.trigger('sync', collection, resp, options);
};
wrapError(collection, options);
return this.sync('crm-replace', this, options)
}
});
// Overrides - if specified in CollectionClass, replace
......@@ -157,6 +312,18 @@
if (origInit) {
return origInit.apply(this, arguments);
}
},
toJSON: function() {
var result = [];
// filter models list, excluding any soft-deleted items
this.each(function(model) {
// if model doesn't track soft-deletes
// or if model tracks soft-deletes and wasn't soft-deleted
if (!model.isSoftDeleted || !model.isSoftDeleted()) {
result.push(model.toJSON());
}
});
return result;
}
});
};
......@@ -315,4 +482,13 @@
}
});
*/
// Wrap an optional error callback with a fallback error event.
var wrapError = function (model, options) {
var error = options.error;
options.error = function(resp) {
if (error) error(model, resp, options);
model.trigger('error', model, resp, options);
};
};
})(cj);
......@@ -5,6 +5,7 @@ var MALFORMED_CONTACT_ID = 'z';
var ContactModel = Backbone.Model.extend({});
CRM.Backbone.extendModel(ContactModel, 'Contact');
CRM.Backbone.trackSaved(ContactModel);
var ContactCollection = Backbone.Collection.extend({
model: ContactModel
......@@ -80,27 +81,33 @@ asyncTest("create/read/delete/read (ok)", function() {
first_name: "George" + TOKEN,
last_name: "Anon" + TOKEN
});
equal(c1.isSaved(), false, "");
// Create the new contact
c1.save({}, {
error: onUnexpectedError,
success: function() {
equal(c1.get("first_name"), "George" + TOKEN, "save() should return new first name");
equal(c1.isSaved(), true, "");
// Fetch the newly created contact
var c2 = new ContactModel({id: c1.get('id')});
equal(c2.isSaved(), true, "");
c2.fetch({
error: onUnexpectedError,
success: function() {
equal(c2.get("first_name"), c1.get("first_name"), "fetch() should return first name");
equal(c2.isSaved(), true, "");
// Destroy the newly created contact
c2.destroy({
error: onUnexpectedError,
success: function() {
equal(c2.isSaved(), true, "");
// Attempt (but fail) to fetch the deleted contact
var c3 = new ContactModel({id: c1.get('id')});
equal(c3.isSaved(), true, "");
c3.fetch({
success: onUnexpectedSuccess,
error: function(model, error) {
......@@ -139,20 +146,27 @@ module('model - update');
asyncTest("update (ok)", function() {
var NICKNAME = "George" + new Date().getTime();
var c = new ContactModel({id: VALID_CONTACT_ID});
c.save({
equal(c.isSaved(), true, "");
c.set({
nick_name: NICKNAME
}, {
});
equal(c.isSaved(), false, "");
c.save({}, {
error: onUnexpectedError,
success: function() {
equal(c.get("nick_name"), NICKNAME, "save() should return new nickname");
var c2 = new ContactModel({id: VALID_CONTACT_ID});
c2.fetch({
error: onUnexpectedError,
success: function() {
equal(c2.get("nick_name"), NICKNAME, "fetch() should return new nickname");
start();
}
_.defer(function(){
equal(c.isSaved(), true, "");
// read back - make sure the save worked
var c2 = new ContactModel({id: VALID_CONTACT_ID});
c2.fetch({
error: onUnexpectedError,
success: function() {
equal(c2.get("nick_name"), NICKNAME, "fetch() should return new nickname");
start();
}
});
});
}
});
......@@ -161,13 +175,19 @@ asyncTest("update (ok)", function() {
asyncTest("update (error)", function() {
var NICKNAME = "George" + new Date().getTime();
var c = new ContactModel({id: VALID_CONTACT_ID});
c.save({
equal(c.isSaved(), true, "");
c.set({
contact_type: 'Not-a.va+lidConta(ype'
}, {
});
equal(c.isSaved(), false, "");
c.save({}, {
success: onUnexpectedSuccess,
error: function(model, error) {
assertApiError(error);
start();
_.defer(function(){
equal(c.isSaved(), false, "");
start();
});
}
});
});
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment