573 lines
19 KiB
JavaScript
573 lines
19 KiB
JavaScript
(function($, _) {
|
|
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 Accepts normal Backbone.sync methods; also accepts "crm-replace"
|
|
* @param model
|
|
* @param options
|
|
* @see tests/qunit/crm-backbone
|
|
*/
|
|
CRM.Backbone.sync = function(method, model, options) {
|
|
var isCollection = _.isArray(model.models);
|
|
|
|
var apiOptions, params;
|
|
if (isCollection) {
|
|
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, model.toCrmAction('get'), model.toCrmCriteria(), apiOptions);
|
|
break;
|
|
// replace all entities matching "x.crmCriteria" with new entities in "x.models"
|
|
case 'crm-replace':
|
|
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"});
|
|
break;
|
|
}
|
|
} else {
|
|
// callback options to pass to CRM.api
|
|
apiOptions = {
|
|
success: function(data) {
|
|
// unwrap data
|
|
var values = _.toArray(data.values);
|
|
if (data.count == 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':
|
|
params = model.toJSON();
|
|
if (!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':
|
|
var apiAction = (method == 'delete') ? 'delete' : 'get';
|
|
params = model.toCrmCriteria();
|
|
if (!params.id) {
|
|
apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
|
|
return;
|
|
}
|
|
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"});
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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"
|
|
* @see tests/qunit/crm-backbone
|
|
*/
|
|
CRM.Backbone.extendModel = function(ModelClass, crmEntityName) {
|
|
// Defaults - if specified in ModelClass, preserve
|
|
_.defaults(ModelClass.prototype, {
|
|
crmEntityName: crmEntityName,
|
|
crmActions: {}, // map: string backboneActionName => string serverSideActionName
|
|
crmReturn: null, // array: list of fields to return
|
|
toCrmAction: function(action) {
|
|
return this.crmActions[action] ? this.crmActions[action] : action;
|
|
},
|
|
toCrmCriteria: function() {
|
|
var result = (this.get('id')) ? {id: this.get('id')} : {};
|
|
if (!_.isEmpty(this.crmReturn)) {
|
|
result.return = this.crmReturn;
|
|
}
|
|
return result;
|
|
},
|
|
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
|
|
_.extend(ModelClass.prototype, {
|
|
sync: CRM.Backbone.sync
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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 (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
|
|
*/
|
|
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,
|
|
crmActions: {}, // map: string backboneActionName => string serverSideActionName
|
|
toCrmAction: function(action) {
|
|
return this.crmActions[action] ? this.crmActions[action] : action;
|
|
},
|
|
toCrmCriteria: function() {
|
|
var result = (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
|
|
if (!_.isEmpty(this.crmReturn)) {
|
|
result.return = this.crmReturn;
|
|
} else if (this.model && !_.isEmpty(this.model.prototype.crmReturn)) {
|
|
result.return = this.model.prototype.crmReturn;
|
|
}
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* 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 criteriaModelClass
|
|
*/
|
|
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) {
|
|
if (!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
|
|
_.extend(CollectionClass.prototype, {
|
|
sync: CRM.Backbone.sync,
|
|
initialize: function(models, options) {
|
|
if (!options) options = {};
|
|
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) {
|
|
if (!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
|
|
* listed in the 'schema'.
|
|
*
|
|
* @return {*}
|
|
*/
|
|
toStrictJSON: function() {
|
|
var schema = this.schema;
|
|
var result = this.toJSON();
|
|
_.each(result, function(value, key) {
|
|
if (!schema[key]) {
|
|
delete result[key];
|
|
}
|
|
});
|
|
return result;
|
|
},
|
|
setRel: function(key, value, options) {
|
|
this.rels = this.rels || {};
|
|
if (this.rels[key] != value) {
|
|
this.rels[key] = value;
|
|
this.trigger("rel:" + key, value);
|
|
}
|
|
},
|
|
getRel: function(key) {
|
|
return this.rels ? this.rels[key] : null;
|
|
}
|
|
});
|
|
|
|
CRM.Backbone.Collection = Backbone.Collection.extend({
|
|
/**
|
|
* Store 'key' on this.rel and automatically copy it to
|
|
* any children.
|
|
*
|
|
* @param key
|
|
* @param value
|
|
* @param initialModels
|
|
*/
|
|
initializeCopyToChildrenRelation: function(key, value, initialModels) {
|
|
this.setRel(key, value, {silent: true});
|
|
this.on('reset', this._copyToChildren, this);
|
|
this.on('add', this._copyToChild, this);
|
|
},
|
|
_copyToChildren: function() {
|
|
var collection = this;
|
|
collection.each(function(model) {
|
|
collection._copyToChild(model);
|
|
});
|
|
},
|
|
_copyToChild: function(model) {
|
|
_.each(this.rels, function(relValue, relKey) {
|
|
model.setRel(relKey, relValue, {silent: true});
|
|
});
|
|
},
|
|
setRel: function(key, value, options) {
|
|
this.rels = this.rels || {};
|
|
if (this.rels[key] != value) {
|
|
this.rels[key] = value;
|
|
this.trigger("rel:" + key, value);
|
|
}
|
|
},
|
|
getRel: function(key) {
|
|
return this.rels ? this.rels[key] : null;
|
|
}
|
|
});
|
|
|
|
/*
|
|
CRM.Backbone.Form = Backbone.Form.extend({
|
|
validate: function() {
|
|
// Add support for form-level validators
|
|
var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
|
|
var self = this;
|
|
if (this.validators) {
|
|
_.each(this.validators, function(validator) {
|
|
var modelErrors = validator(this.getValue());
|
|
|
|
// The following if() has been copied-pasted from the parent's
|
|
// handling of model-validators. They are similar in that the errors are
|
|
// probably keyed by field names... but not necessarily, so we use _others
|
|
// as a fallback.
|
|
if (modelErrors) {
|
|
var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
|
|
|
|
//If errors are not in object form then just store on the error object
|
|
if (!isDictionary) {
|
|
errors._others = errors._others || [];
|
|
errors._others.push(modelErrors);
|
|
}
|
|
|
|
//Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
|
|
if (isDictionary) {
|
|
_.each(modelErrors, function(val, key) {
|
|
//Set error on field if there isn't one already
|
|
if (self.fields[key] && !errors[key]) {
|
|
self.fields[key].setError(val);
|
|
errors[key] = val;
|
|
}
|
|
|
|
else {
|
|
//Otherwise add to '_others' key
|
|
errors._others = errors._others || [];
|
|
var tmpErr = {};
|
|
tmpErr[key] = val;
|
|
errors._others.push(tmpErr);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
});
|
|
}
|
|
return _.isEmpty(errors) ? null : errors;
|
|
}
|
|
});
|
|
*/
|
|
|
|
// 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, optio);
|
|
model.trigger('error', model, resp, options);
|
|
};
|
|
};
|
|
})(CRM.$, CRM._);
|