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

Merge pull request #1192 from totten/CRM-12943-persistence

CRM-12943 - Backbone.sync persistence using CRM.api
parents 45f0564b 5eccc958
Branches
Tags
No related merge requests found
......@@ -2,6 +2,165 @@
var CRM = (window.CRM) ? (window.CRM) : (window.CRM = {});
if (!CRM.Backbone) CRM.Backbone = {};
/**
* Backbone.sync provider which uses CRM.api() for I/O.
* To support CRUD operations, model classes must be defined with a "crmEntityName" property.
* To load collections using API queries, set the "crmCriteria" property or override the
* method "toCrmCriteria".
*
* @param method
* @param model
* @param options
* @see tests/qunit/crm-backbone
*/
CRM.Backbone.sync = function(method, model, options) {
var isCollection = _.isArray(model.models);
if (isCollection) {
var apiOptions = {
success: function(data) {
// unwrap data
options.success(_.toArray(data.values));
},
error: function(data) {
// CRM.api displays errors by default, but Backbone.sync
// protocol requires us to override "error". This restores
// the default behavior.
$().crmError(data.error_message, ts('Error'));
options.error(data);
}
};
switch (method) {
case 'read':
CRM.api(model.crmEntityName, 'get', model.toCrmCriteria(), apiOptions);
break;
default:
apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
break;
}
} else {
// callback options to pass to CRM.api
var apiOptions = {
success: function(data) {
// unwrap data
var values = _.toArray(data['values']);
if (data.count == 1) {
options.success(values[0]);
} else {
data.is_error = 1;
data.error_message = ts("Expected exactly one response");
apiOptions.error(data);
}
},
error: function(data) {
// CRM.api displays errors by default, but Backbone.sync
// protocol requires us to override "error". This restores
// the default behavior.
$().crmError(data.error_message, ts('Error'));
options.error(data);
}
};
switch (method) {
case 'create': // pass-through
case 'update':
CRM.api(model.crmEntityName, 'create', model.toJSON(), apiOptions);
break;
case 'read':
case 'delete':
var apiAction = (method == 'delete') ? 'delete' : 'get';
var params = model.toCrmCriteria();
if (!params.id) {
apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
return;
}
CRM.api(model.crmEntityName, apiAction, params, apiOptions);
break;
default:
apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
}
}
};
/**
* Connect a "model" class to CiviCRM's APIv3
*
* @code
* // Setup class
* var ContactModel = Backbone.Model.extend({});
* CRM.Backbone.extendModel(ContactModel, "Contact");
*
* // Use class
* c = new ContactModel({id: 3});
* c.fetch();
* @endcode
*
* @param Class ModelClass
* @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
* @see tests/qunit/crm-backbone
*/
CRM.Backbone.extendModel = function(ModelClass, crmEntityName) {
// Defaults - if specified in ModelClass, preserve
_.defaults(ModelClass.prototype, {
crmEntityName: crmEntityName,
toCrmCriteria: function() {
return (this.get('id')) ? {id: this.get('id')} : {};
}
});
// Overrides - if specified in ModelClass, replace
_.extend(ModelClass.prototype, {
sync: CRM.Backbone.sync
});
};
/**
* 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
*
* @code
* // Setup class
* var ContactModel = Backbone.Model.extend({});
* CRM.Backbone.extendModel(ContactModel, "Contact");
* var ContactCollection = Backbone.Collection.extend({
* model: ContactModel
* });
* CRM.Backbone.extendCollection(ContactCollection);
*
* // Use class
* var c = new ContactCollection([], {
* crmCriteria: {contact_type: 'Organization'}
* });
* c.fetch();
* @endcode
*
* @param Class CollectionClass
* @see tests/qunit/crm-backbone
*/
CRM.Backbone.extendCollection = function(CollectionClass) {
var origInit = CollectionClass.prototype.initialize;
// Defaults - if specified in CollectionClass, preserve
_.defaults(CollectionClass.prototype, {
crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
toCrmCriteria: function() {
return this.crmCriteria || {};
}
});
// Overrides - if specified in CollectionClass, replace
_.extend(CollectionClass.prototype, {
sync: CRM.Backbone.sync,
initialize: function(models, options) {
options || (options = {});
if (options.crmCriteria) {
this.crmCriteria = options.crmCriteria;
}
if (origInit) {
return origInit.apply(this, arguments);
}
}
});
};
CRM.Backbone.Model = Backbone.Model.extend({
/**
* Return JSON version of model -- but only include fields that are
......@@ -12,8 +171,8 @@
toStrictJSON: function() {
var schema = this.schema;
var result = this.toJSON();
_.each(result, function(value, key){
if (! schema[key]) {
_.each(result, function(value, key) {
if (!schema[key]) {
delete result[key];
}
});
......@@ -23,7 +182,7 @@
this.rels = this.rels || {};
if (this.rels[key] != value) {
this.rels[key] = value;
this.trigger("rel:"+key, value);
this.trigger("rel:" + key, value);
}
},
getRel: function(key) {
......@@ -47,12 +206,12 @@
},
_copyToChildren: function() {
var collection = this;
collection.each(function(model){
collection.each(function(model) {
collection._copyToChild(model);
});
},
_copyToChild: function(model) {
_.each(this.rels, function(relValue, relKey){
_.each(this.rels, function(relValue, relKey) {
model.setRel(relKey, relValue, {silent: true});
});
},
......@@ -60,7 +219,7 @@
this.rels = this.rels || {};
if (this.rels[key] != value) {
this.rels[key] = value;
this.trigger("rel:"+key, value);
this.trigger("rel:" + key, value);
}
},
getRel: function(key) {
......
/* ------------ Fixtures/constants ------------ */
var VALID_CONTACT_ID = 3;
var INVALID_CONTACT_ID = 'z';
var ContactModel = Backbone.Model.extend({});
CRM.Backbone.extendModel(ContactModel, 'Contact');
var ContactCollection = Backbone.Collection.extend({
model: ContactModel
});
CRM.Backbone.extendCollection(ContactCollection);
/* ------------ Assertions ------------ */
/**
* Assert "result" contains an API error
* @param result
*/
function assertApiError(result) {
equal(1, result.is_error, 'Expected error boolean');
ok(result.error_message.length > 0, 'Expected error message')
}
/**
* When calling an AJAX operation which should return successfully,
* make sure that there's no error by setting a callback (error: onUnexpectedError)
*/
function onUnexpectedError(ignore, result) {
if (result && result.error_message) {
ok(false, "API returned an unexpected error: " + result.error_message);
} else {
ok(false, "API returned an unexpected error: (missing message)");
}
start();
}
/**
* When calling an AJAX operation which should return an error,
* make sure that there's no success by setting a callback (success: onUnexpectedSuccess)
*/
function onUnexpectedSuccess(ignore) {
ok(false, "API succeeded - but failure was expected");
start();
}
/* ------------ Test cases ------------ */
module('model - read');
asyncTest("fetch (ok)", function() {
var c = new ContactModel({id: VALID_CONTACT_ID});
c.fetch({
error: onUnexpectedError,
success: function() {
notEqual(-1, _.indexOf(['Individual', 'Household', 'Organization'], c.get('contact_type')), 'Loaded contact with valid contact_type');
ok(c.get('display_name') != '', 'Loaded contact with valid name');
start();
}
});
});
asyncTest("fetch (error)", function() {
var c = new ContactModel({id: INVALID_CONTACT_ID});
c.fetch({
success: onUnexpectedSuccess,
error: function(model, error) {
assertApiError(error);
start();
}
});
});
module('model - create');
asyncTest("create/read/delete/read (ok)", function() {
var TOKEN = new Date().getTime();
var c1 = new ContactModel({
contact_type: "Individual",
first_name: "George" + TOKEN,
last_name: "Anon" + TOKEN
});
// Create the new contact
c1.save({}, {
error: onUnexpectedError,
success: function() {
equal(c1.get("first_name"), "George" + TOKEN, "save() should return new first name");
// Fetch the newly created contact
var c2 = new ContactModel({id: c1.get('id')});
c2.fetch({
error: onUnexpectedError,
success: function() {
equal(c2.get("first_name"), c1.get("first_name"), "fetch() should return first name");
// Destroy the newly created contact
c2.destroy({
error: onUnexpectedError,
success: function() {
// Attempt (but fail) to fetch the deleted contact
var c3 = new ContactModel({id: c1.get('id')});
c3.fetch({
success: onUnexpectedSuccess,
error: function(model, error) {
assertApiError(error);
start();
}
}); // fetch
}
}); // destroy
}
}); // fetch
}
}); // save
});
asyncTest("create (error)", function() {
var TOKEN = new Date().getTime();
var c1 = new ContactModel({
// MISSING: contact_type: "Individual",
first_name: "George" + TOKEN,
last_name: "Anon" + TOKEN
});
// Create the new contact
c1.save({}, {
success: onUnexpectedSuccess,
error: function(model, error) {
assertApiError(error);
start();
}
});
});
module('model - update');
asyncTest("update (ok)", function() {
var NICKNAME = "George" + new Date().getTime();
var c = new ContactModel({id: VALID_CONTACT_ID});
c.save({
nick_name: NICKNAME
}, {
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();
}
});
}
});
});
asyncTest("update (error)", function() {
var NICKNAME = "George" + new Date().getTime();
var c = new ContactModel({id: VALID_CONTACT_ID});
c.save({
contact_type: 'Not-a.va+lidConta(ype'
}, {
success: onUnexpectedSuccess,
error: function(model, error) {
assertApiError(error);
start();
}
});
});
module('collection - read');
asyncTest("fetch by contact_type (1+ results)", function() {
var c = new ContactCollection([], {
crmCriteria: {
contact_type: 'Organization'
}
});
c.fetch({
error: onUnexpectedError,
success: function() {
ok(c.models.length > 0, "Expected at least one contact");
c.each(function(model) {
equal(model.get('contact_type'), 'Organization', 'Expected contact with type organization');
ok(model.get('display_name') != '', 'Expected contact with valid name');
});
start();
}
});
});
asyncTest("fetch by crazy name (0 results)", function() {
var c = new ContactCollection([], {
crmCriteria: {
display_name: 'asdf23vmlk2309lk2lkasdk-23ASDF32f'
}
});
c.fetch({
error: onUnexpectedError,
success: function() {
equal(c.models.length, 0, "Expected no contacts");
start();
}
});
});
asyncTest("fetch by malformed ID (error)", function() {
var c = new ContactCollection([], {
crmCriteria: {
id: INVALID_CONTACT_ID
}
});
c.fetch({
success: onUnexpectedSuccess,
error: function(collection, error) {
assertApiError(error);
start();
}
});
});
<?php
CRM_Core_Resources::singleton()
->addScriptFile('civicrm', 'packages/backbone/json2.js', 100, 'html-header', FALSE)
->addScriptFile('civicrm', 'packages/backbone/underscore.js', 110, 'html-header', FALSE)
->addScriptFile('civicrm', 'packages/backbone/backbone.js', 120, 'html-header')
->addScriptFile('civicrm', 'packages/backbone/backbone.modelbinder.js', 125, 'html-header', FALSE)
->addScriptFile('civicrm', 'js/crm.backbone.js', 130, 'html-header', FALSE);
// CRM_Core_Resources::singleton()->addScriptFile(...);
// CRM_Core_Resources::singleton()->addStyleFile(...);
// CRM_Core_Resources::singleton()->addSetting(...);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment