-- Indentation fixes
[civicrm-core.git] / js / crm.backbone.js
index f12d671006f0ab4ca7daea1b34e701fe0f3199ab..746ddaf7be3559ee8aa0d9529885de9a8b85d71a 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
         case 'read':
           CRM.api(model.crmEntityName, '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, 'replace', params, apiOptions);
+          break;
         default:
           apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
           break;
       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, 'create', params, apiOptions);
+          } else {
+            CRM.api(model.crmEntityName, 'duplicate', params, apiOptions);
+          }
           break;
         case 'read':
         case 'delete':
       crmEntityName: crmEntityName,
       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
    *   crmCriteria: {contact_type: 'Organization'}
    * });
    * c.fetch();
+   * c.get(123).set('property', 'value');
+   * c.get(456).setDeleted(true);
+   * c.save();
    * @endcode
    *
    * @param Class CollectionClass
     _.defaults(CollectionClass.prototype, {
       crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
       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.
+       * 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:
-       *   - success: function(model)
-       *   - error: function(collection, error)
-       *   - defaults: Object values to put on newly created model (if needed)
+       * @param Object options - accepts "success" and "error" callbacks
        */
-      fetchCreate: function(options) {
+      save: 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);
-            }
-          }
-        });
+        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
         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);