/sites/all/modules/contrib/civicrm/js/crm.backbone.js
JavaScript | 572 lines | 335 code | 21 blank | 216 comment | 57 complexity | 77dec53a87ea90d76fe0e86401de06d3 MD5 | raw file
- (function($, _, Backbone) {
- 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._, CRM.BB);