-- Indentation fixes
[civicrm-core.git] / js / crm.backbone.js
index 2e2e381fe82804720dde8c7b939c8673fb91edaf..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) : {};
+      },
+
+      /**
+       * 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
         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;
       }
     });
   };
     }
   });
   */
+
+  // 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);