* 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
* });
* 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(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.
+ * 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});
+ }, 500),
+
+ /**
+ * 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 (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);