crm.backbone.js - Dial-down the delay on propagating changes from crmCriteriaModel...
[civicrm-core.git] / js / crm.backbone.js
index f12d671006f0ab4ca7daea1b34e701fe0f3199ab..647d70c735a9a9aa635d7102fd9bda03e3825329 100644 (file)
@@ -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
       };
       switch (method) {
         case 'read':
-          CRM.api(model.crmEntityName, 'get', model.toCrmCriteria(), apiOptions);
+          CRM.api(model.crmEntityName, model.toCrmAction('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, model.toCrmAction('replace'), params, apiOptions);
           break;
         default:
           apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
       switch (method) {
         case 'create': // pass-through
         case 'update':
-          CRM.api(model.crmEntityName, 'create', model.toJSON(), apiOptions);
+          var params = model.toJSON();
+          params.options || (params.options = {});
+          params.options.reload = 1;
+          if (!model._isDuplicate) {
+            CRM.api(model.crmEntityName, model.toCrmAction('create'), params, apiOptions);
+          } else {
+            CRM.api(model.crmEntityName, model.toCrmAction('duplicate'), params, apiOptions);
+          }
           break;
         case 'read':
         case 'delete':
@@ -73,7 +87,7 @@
             apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
             return;
           }
-          CRM.api(model.crmEntityName, apiAction, params, apiOptions);
+          CRM.api(model.crmEntityName, model.toCrmAction(apiAction), params, apiOptions);
           break;
         default:
           apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
     // Defaults - if specified in ModelClass, preserve
     _.defaults(ModelClass.prototype, {
       crmEntityName: crmEntityName,
+      crmActions: {}, // map: string backboneActionName => string serverSideActionName
+      toCrmAction: function(action) {
+        return this.crmActions[action] ? this.crmActions[action] : action;
+      },
       toCrmCriteria: function() {
         return (this.get('id')) ? {id: this.get('id')} : {};
+      },
+      duplicate: function() {
+        var newModel = new ModelClass(this.toJSON());
+        newModel._isDuplicate = true;
+        if (newModel.setModified) newModel.setModified();
+        newModel.listenTo(newModel, 'sync', function(){
+          // may get called on subsequent resaves -- don't care!
+          delete newModel._isDuplicate;
+        });
+        return newModel;
       }
     });
     // Overrides - if specified in ModelClass, replace
   };
 
   /**
+   * 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.isModified();
+        return result;
+      },
+      isModified: function() {
+        return this._modified;
+      },
+      _saved_onchange: function(model, options) {
+        if (options.parse) return;
+        // console.log('change', model.changedAttributes(), model.previousAttributes());
+        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
    * });
    * CRM.Backbone.extendCollection(ContactCollection);
    *
-   * // Use class
+   * // Use class (with passive criteria)
    * var c = new ContactCollection([], {
    *   crmCriteria: {contact_type: 'Organization'}
    * });
    * c.fetch();
+   * c.get(123).set('property', 'value');
+   * c.get(456).setDeleted(true);
+   * c.save();
+   *
+   * // Use class (with active criteria)
+   * var criteriaModel = new SomeModel({
+   *     contact_type: 'Organization'
+   * });
+   * var c = new ContactCollection([], {
+   *   crmCriteriaModel: criteriaModel
+   * });
+   * c.fetch();
+   * c.get(123).set('property', 'value');
+   * c.get(456).setDeleted(true);
+   * c.save();
    * @endcode
    *
+   *
    * @param Class CollectionClass
    * @see tests/qunit/crm-backbone
    */
     // Defaults - if specified in CollectionClass, preserve
     _.defaults(CollectionClass.prototype, {
       crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
+      crmActions: {}, // map: string backboneActionName => string serverSideActionName
+      toCrmAction: function(action) {
+        return this.crmActions[action] ? this.crmActions[action] : action;
+      },
       toCrmCriteria: function() {
-        return this.crmCriteria || {};
+        return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
       },
 
       /**
-       * Find a single match, or create a (new) matching record. If a new record is created,
-       * it will be added to the collection but NOT saved.
+       * Get an object which represents this collection's criteria
+       * as a live model. Any changes to the model will be applied
+       * to the collection, and the collection will be refreshed.
        *
-       * @param Object options:
-       *   - success: function(model)
-       *   - error: function(collection, error)
-       *   - defaults: Object values to put on newly created model (if needed)
+       * @param criteriaModelClass
        */
-      fetchCreate: function(options) {
-        options || (options = {});
-        this.fetch({
-          success: function(collection) {
-            if (collection.length == 0) {
-              var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
-              var model = collection._prepareModel(attrs, options);
-              collection.add(model, options);
-              options.success(model);
-            } else if (collection.length == 1) {
-              options.success(collection.first());
-            } else {
-              options.error(collection, {
-                is_error: 1,
-                error_message: 'Too many matches'
-              });
-            }
-          },
-          error: function(collection, errorData) {
-            if (options.error) {
-              options.error(collection, errorData);
-            }
-          }
+      setCriteriaModel: function(criteriaModel) {
+        var collection = this;
+        this.crmCriteria = criteriaModel.toJSON();
+        this.listenTo(criteriaModel, 'change', function() {
+          collection.crmCriteria = criteriaModel.toJSON();
+          collection.debouncedFetch();
         });
+      },
+
+      debouncedFetch: _.debounce(function() {
+        this.fetch({reset: true});
+      }, 100),
+
+      /**
+       * 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
       sync: CRM.Backbone.sync,
       initialize: function(models, options) {
         options || (options = {});
-        if (options.crmCriteria) {
+        if (options.crmCriteriaModel) {
+          this.setCriteriaModel(options.crmCriteriaModel);
+        } else if (options.crmCriteria) {
           this.crmCriteria = options.crmCriteria;
         }
+        if (options.crmActions) {
+          this.crmActions = _.extend(this.crmActions, options.crmActions);
+        }
         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;
+      }
+    });
+  };
+
+  /**
+   * Find a single record, or create a new record.
+   *
+   * @param Object options:
+   *   - CollectionClass: class
+   *   - crmCriteria: Object values to search/default on
+   *   - defaults: Object values to put on newly created model (if needed)
+   *   - success: function(model)
+   *   - error: function(collection, error)
+   */
+   CRM.Backbone.findCreate = function(options) {
+     options || (options = {});
+     var collection = new options.CollectionClass([], {
+       crmCriteria: options.crmCriteria
+     });
+     collection.fetch({
+      success: function(collection) {
+        if (collection.length == 0) {
+          var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
+          var model = collection._prepareModel(attrs, options);
+          options.success(model);
+        } else if (collection.length == 1) {
+          options.success(collection.first());
+        } else {
+          options.error(collection, {
+            is_error: 1,
+            error_message: 'Too many matches'
+          });
+        }
+      },
+      error: function(collection, errorData) {
+        if (options.error) {
+          options.error(collection, errorData);
+        }
       }
     });
   };
 
+
   CRM.Backbone.Model = Backbone.Model.extend({
     /**
      * Return JSON version of model -- but only include fields that are
     }
   });
   */
+
+  // 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);