CRM-12943 - Add CRM.Backbone.sync with qUnit tests
authorTim Otten <totten@civicrm.org>
Fri, 19 Jul 2013 01:47:25 +0000 (18:47 -0700)
committerTim Otten <totten@civicrm.org>
Fri, 19 Jul 2013 01:50:22 +0000 (18:50 -0700)
----------------------------------------
* CRM-12943: Make HTML prototype of job UI functional
  http://issues.civicrm.org/jira/browse/CRM-12943

js/crm.backbone.js
tests/qunit/crm-backbone/test.js [new file with mode: 0644]
tests/qunit/crm-backbone/test.php [new file with mode: 0644]

index 27302e748ef10ca7ea98f566dd77eeb8e9263eba..a4e4f716883d7254d88ee432d121b47cf5ebb85e 100644 (file)
@@ -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 (file)
index 0000000..9285cf3
--- /dev/null
@@ -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 (file)
index 0000000..a8fd50d
--- /dev/null
@@ -0,0 +1,11 @@
+<?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(...);