From: Tim Otten Date: Fri, 19 Jul 2013 01:47:25 +0000 (-0700) Subject: CRM-12943 - Add CRM.Backbone.sync with qUnit tests X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=231a4c0f7ca0d58683052d5de844faba248a934c;p=civicrm-core.git CRM-12943 - Add CRM.Backbone.sync with qUnit tests ---------------------------------------- * CRM-12943: Make HTML prototype of job UI functional http://issues.civicrm.org/jira/browse/CRM-12943 --- diff --git a/js/crm.backbone.js b/js/crm.backbone.js index 27302e748e..a4e4f71688 100644 --- a/js/crm.backbone.js +++ b/js/crm.backbone.js @@ -2,6 +2,161 @@ 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 + */ + 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 (values.length == 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': + var params = model.toCrmCriteria(); + if (!params.id) { + apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName}); + return; + } + CRM.api(model.crmEntityName, 'get', params, apiOptions); + break; + case 'delete': + 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" + */ + 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 + */ + 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 diff --git a/tests/qunit/crm-backbone/test.js b/tests/qunit/crm-backbone/test.js new file mode 100644 index 0000000000..9285cf31c3 --- /dev/null +++ b/tests/qunit/crm-backbone/test.js @@ -0,0 +1,149 @@ +/* ------ 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 ------ */ + +function assertApiError(result) { + equal(1, result.is_error, 'Expected error boolean'); + ok(result.error_message.length > 0, 'Expected error message') +} +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(); +} +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 - 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(); + } + }); +}); + diff --git a/tests/qunit/crm-backbone/test.php b/tests/qunit/crm-backbone/test.php new file mode 100644 index 0000000000..a8fd50d9ac --- /dev/null +++ b/tests/qunit/crm-backbone/test.php @@ -0,0 +1,11 @@ +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(...);