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
--- /dev/null
+/* ------ 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();
+ }
+ });
+});
+
--- /dev/null
+<?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(...);