2 if (!CRM
.Backbone
) CRM
.Backbone
= {};
5 * Backbone.sync provider which uses CRM.api() for I/O.
6 * To support CRUD operations, model classes must be defined with a "crmEntityName" property.
7 * To load collections using API queries, set the "crmCriteria" property or override the
8 * method "toCrmCriteria".
10 * @param method Accepts normal Backbone.sync methods; also accepts "crm-replace"
13 * @see tests/qunit/crm-backbone
15 CRM
.Backbone
.sync = function(method
, model
, options
) {
16 var isCollection
= _
.isArray(model
.models
);
20 success: function(data
) {
22 options
.success(_
.toArray(data
.values
));
24 error: function(data
) {
25 // CRM.api displays errors by default, but Backbone.sync
26 // protocol requires us to override "error". This restores
27 // the default behavior.
28 $().crmError(data
.error_message
, ts('Error'));
34 CRM
.api(model
.crmEntityName
, model
.toCrmAction('get'), model
.toCrmCriteria(), apiOptions
);
36 // replace all entities matching "x.crmCriteria" with new entities in "x.models"
38 var params
= this.toCrmCriteria();
40 params
.values
= this.toJSON();
41 CRM
.api(model
.crmEntityName
, model
.toCrmAction('replace'), params
, apiOptions
);
44 apiOptions
.error({is_error
: 1, error_message
: "CRM.Backbone.sync(" + method
+ ") not implemented for collections"});
48 // callback options to pass to CRM.api
50 success: function(data
) {
52 var values
= _
.toArray(data
['values']);
53 if (data
.count
== 1) {
54 options
.success(values
[0]);
57 data
.error_message
= ts("Expected exactly one response");
58 apiOptions
.error(data
);
61 error: function(data
) {
62 // CRM.api displays errors by default, but Backbone.sync
63 // protocol requires us to override "error". This restores
64 // the default behavior.
65 $().crmError(data
.error_message
, ts('Error'));
70 case 'create': // pass-through
72 var params
= model
.toJSON();
73 params
.options
|| (params
.options
= {});
74 params
.options
.reload
= 1;
75 if (!model
._isDuplicate
) {
76 CRM
.api(model
.crmEntityName
, model
.toCrmAction('create'), params
, apiOptions
);
78 CRM
.api(model
.crmEntityName
, model
.toCrmAction('duplicate'), params
, apiOptions
);
83 var apiAction
= (method
== 'delete') ? 'delete' : 'get';
84 var params
= model
.toCrmCriteria();
86 apiOptions
.error({is_error
: 1, error_message
: 'Missing ID for ' + model
.crmEntityName
});
89 CRM
.api(model
.crmEntityName
, model
.toCrmAction(apiAction
), params
, apiOptions
);
92 apiOptions
.error({is_error
: 1, error_message
: "CRM.Backbone.sync(" + method
+ ") not implemented for models"});
98 * Connect a "model" class to CiviCRM's APIv3
102 * var ContactModel = Backbone.Model.extend({});
103 * CRM.Backbone.extendModel(ContactModel, "Contact");
106 * c = new ContactModel({id: 3});
110 * @param Class ModelClass
111 * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
112 * @see tests/qunit/crm-backbone
114 CRM
.Backbone
.extendModel = function(ModelClass
, crmEntityName
) {
115 // Defaults - if specified in ModelClass, preserve
116 _
.defaults(ModelClass
.prototype, {
117 crmEntityName
: crmEntityName
,
118 crmActions
: {}, // map: string backboneActionName => string serverSideActionName
119 crmReturn
: null, // array: list of fields to return
120 toCrmAction: function(action
) {
121 return this.crmActions
[action
] ? this.crmActions
[action
] : action
;
123 toCrmCriteria: function() {
124 var result
= (this.get('id')) ? {id
: this.get('id')} : {};
125 if (this.crmReturn
!= null) {
126 result
.return = this.crmReturn
;
130 duplicate: function() {
131 var newModel
= new ModelClass(this.toJSON());
132 newModel
._isDuplicate
= true;
133 if (newModel
.setModified
) newModel
.setModified();
134 newModel
.listenTo(newModel
, 'sync', function(){
135 // may get called on subsequent resaves -- don't care!
136 delete newModel
._isDuplicate
;
141 // Overrides - if specified in ModelClass, replace
142 _
.extend(ModelClass
.prototype, {
143 sync
: CRM
.Backbone
.sync
148 * Configure a model class to track whether a model has unsaved changes.
151 * - setModified() - flag the model as modified/dirty
152 * - isSaved() - return true if there have been no changes to the data since the last fetch or save
154 * - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
156 * Note: You should not directly call isSaved() within the context of the success/error/sync callback;
157 * I haven't found a way to make isSaved() behave correctly within these callbacks without patching
158 * Backbone. Instead, attach an event listener to the 'saved' event.
162 CRM
.Backbone
.trackSaved = function(ModelClass
) {
163 // Retain references to some of the original class's functions
164 var Parent
= _
.pick(ModelClass
.prototype, 'initialize', 'save', 'fetch');
167 var onSyncSuccess = function() {
168 this._modified
= false;
169 if (this._oldModified
.length
> 0) {
170 this._oldModified
.pop();
172 this.trigger('saved', this, this.isSaved());
174 var onSaveError = function() {
175 if (this._oldModified
.length
> 0) {
176 this._modified
= this._oldModified
.pop();
177 this.trigger('saved', this, this.isSaved());
181 // Defaults - if specified in ModelClass, preserve
182 _
.defaults(ModelClass
.prototype, {
183 isSaved: function() {
184 var result
= !this.isNew() && !this.isModified();
187 isModified: function() {
188 return this._modified
;
190 _saved_onchange: function(model
, options
) {
191 if (options
.parse
) return;
192 // console.log('change', model.changedAttributes(), model.previousAttributes());
195 setModified: function() {
196 var oldModified
= this._modified
;
197 this._modified
= true;
199 this.trigger('saved', this, this.isSaved());
204 // Overrides - if specified in ModelClass, replace
205 _
.extend(ModelClass
.prototype, {
206 initialize: function(options
) {
207 this._modified
= false;
208 this._oldModified
= [];
209 this.listenTo(this, 'change', this._saved_onchange
);
210 this.listenTo(this, 'error', onSaveError
);
211 this.listenTo(this, 'sync', onSyncSuccess
);
212 if (Parent
.initialize
) {
213 return Parent
.initialize
.apply(this, arguments
);
217 // we'll assume success
218 this._oldModified
.push(this._modified
);
219 return Parent
.save
.apply(this, arguments
);
222 this._oldModified
.push(this._modified
);
223 return Parent
.fetch
.apply(this, arguments
);
229 * Configure a model class to support client-side soft deletion.
230 * One can call "model.setDeleted(BOOLEAN)" to flag an entity for
231 * deletion (or not) -- however, deletion will be deferred until save()
235 * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
236 * isSoftDeleted() - determine whether model has been soft-deleted
238 * softDelete(model, is_deleted) -- change value of is_deleted
242 CRM
.Backbone
.trackSoftDelete = function(ModelClass
) {
243 // Retain references to some of the original class's functions
244 var Parent
= _
.pick(ModelClass
.prototype, 'save');
246 // Defaults - if specified in ModelClass, preserve
247 _
.defaults(ModelClass
.prototype, {
248 is_soft_deleted
: false,
249 setSoftDeleted: function(is_deleted
) {
250 if (this.is_soft_deleted
!= is_deleted
) {
251 this.is_soft_deleted
= is_deleted
;
252 this.trigger('softDelete', this, is_deleted
);
253 if (this.setModified
) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
256 isSoftDeleted: function() {
257 return this.is_soft_deleted
;
261 // Overrides - if specified in ModelClass, replace
262 _
.extend(ModelClass
.prototype, {
263 save: function(attributes
, options
) {
264 if (this.isSoftDeleted()) {
265 return this.destroy(options
);
267 return Parent
.save
.apply(this, arguments
);
274 * Connect a "collection" class to CiviCRM's APIv3
276 * Note: the collection supports a special property, crmCriteria, which is an array of
277 * query options to send to the API.
281 * var ContactModel = Backbone.Model.extend({});
282 * CRM.Backbone.extendModel(ContactModel, "Contact");
283 * var ContactCollection = Backbone.Collection.extend({
284 * model: ContactModel
286 * CRM.Backbone.extendCollection(ContactCollection);
288 * // Use class (with passive criteria)
289 * var c = new ContactCollection([], {
290 * crmCriteria: {contact_type: 'Organization'}
293 * c.get(123).set('property', 'value');
294 * c.get(456).setDeleted(true);
297 * // Use class (with active criteria)
298 * var criteriaModel = new SomeModel({
299 * contact_type: 'Organization'
301 * var c = new ContactCollection([], {
302 * crmCriteriaModel: criteriaModel
305 * c.get(123).set('property', 'value');
306 * c.get(456).setDeleted(true);
311 * @param Class CollectionClass
312 * @see tests/qunit/crm-backbone
314 CRM
.Backbone
.extendCollection = function(CollectionClass
) {
315 var origInit
= CollectionClass
.prototype.initialize
;
316 // Defaults - if specified in CollectionClass, preserve
317 _
.defaults(CollectionClass
.prototype, {
318 crmEntityName
: CollectionClass
.prototype.model
.prototype.crmEntityName
,
319 crmActions
: {}, // map: string backboneActionName => string serverSideActionName
320 toCrmAction: function(action
) {
321 return this.crmActions
[action
] ? this.crmActions
[action
] : action
;
323 toCrmCriteria: function() {
324 var result
= (this.crmCriteria
) ? _
.extend({}, this.crmCriteria
) : {};
325 if (this.crmReturn
!= null) {
326 result
.return = this.crmReturn
;
327 } else if (this.model
&& this.model
.prototype.crmReturn
!= null) {
328 result
.return = this.model
.prototype.crmReturn
;
334 * Get an object which represents this collection's criteria
335 * as a live model. Any changes to the model will be applied
336 * to the collection, and the collection will be refreshed.
338 * @param criteriaModelClass
340 setCriteriaModel: function(criteriaModel
) {
341 var collection
= this;
342 this.crmCriteria
= criteriaModel
.toJSON();
343 this.listenTo(criteriaModel
, 'change', function() {
344 collection
.crmCriteria
= criteriaModel
.toJSON();
345 collection
.debouncedFetch();
349 debouncedFetch
: _
.debounce(function() {
350 this.fetch({reset
: true});
354 * Reconcile the server's collection with the client's collection.
355 * New/modified items from the client will be saved/updated on the
356 * server. Deleted items from the client will be deleted on the
359 * @param Object options - accepts "success" and "error" callbacks
361 save: function(options
) {
362 options
|| (options
= {});
363 var collection
= this;
364 var success
= options
.success
;
365 options
.success = function(resp
) {
366 // Ensure attributes are restored during synchronous saves.
367 collection
.reset(resp
, options
);
368 if (success
) success(collection
, resp
, options
);
369 // collection.trigger('sync', collection, resp, options);
371 wrapError(collection
, options
);
373 return this.sync('crm-replace', this, options
)
376 // Overrides - if specified in CollectionClass, replace
377 _
.extend(CollectionClass
.prototype, {
378 sync
: CRM
.Backbone
.sync
,
379 initialize: function(models
, options
) {
380 options
|| (options
= {});
381 if (options
.crmCriteriaModel
) {
382 this.setCriteriaModel(options
.crmCriteriaModel
);
383 } else if (options
.crmCriteria
) {
384 this.crmCriteria
= options
.crmCriteria
;
386 if (options
.crmActions
) {
387 this.crmActions
= _
.extend(this.crmActions
, options
.crmActions
);
390 return origInit
.apply(this, arguments
);
395 // filter models list, excluding any soft-deleted items
396 this.each(function(model
) {
397 // if model doesn't track soft-deletes
398 // or if model tracks soft-deletes and wasn't soft-deleted
399 if (!model
.isSoftDeleted
|| !model
.isSoftDeleted()) {
400 result
.push(model
.toJSON());
409 * Find a single record, or create a new record.
411 * @param Object options:
412 * - CollectionClass: class
413 * - crmCriteria: Object values to search/default on
414 * - defaults: Object values to put on newly created model (if needed)
415 * - success: function(model)
416 * - error: function(collection, error)
418 CRM
.Backbone
.findCreate = function(options
) {
419 options
|| (options
= {});
420 var collection
= new options
.CollectionClass([], {
421 crmCriteria
: options
.crmCriteria
424 success: function(collection
) {
425 if (collection
.length
== 0) {
426 var attrs
= _
.extend({}, collection
.crmCriteria
, options
.defaults
|| {});
427 var model
= collection
._prepareModel(attrs
, options
);
428 options
.success(model
);
429 } else if (collection
.length
== 1) {
430 options
.success(collection
.first());
432 options
.error(collection
, {
434 error_message
: 'Too many matches'
438 error: function(collection
, errorData
) {
440 options
.error(collection
, errorData
);
447 CRM
.Backbone
.Model
= Backbone
.Model
.extend({
449 * Return JSON version of model -- but only include fields that are
450 * listed in the 'schema'.
454 toStrictJSON: function() {
455 var schema
= this.schema
;
456 var result
= this.toJSON();
457 _
.each(result
, function(value
, key
) {
464 setRel: function(key
, value
, options
) {
465 this.rels
= this.rels
|| {};
466 if (this.rels
[key
] != value
) {
467 this.rels
[key
] = value
;
468 this.trigger("rel:" + key
, value
);
471 getRel: function(key
) {
472 return this.rels
? this.rels
[key
] : null;
476 CRM
.Backbone
.Collection
= Backbone
.Collection
.extend({
478 * Store 'key' on this.rel and automatically copy it to
483 * @param initialModels
485 initializeCopyToChildrenRelation: function(key
, value
, initialModels
) {
486 this.setRel(key
, value
, {silent
: true});
487 this.on('reset', this._copyToChildren
, this);
488 this.on('add', this._copyToChild
, this);
490 _copyToChildren: function() {
491 var collection
= this;
492 collection
.each(function(model
) {
493 collection
._copyToChild(model
);
496 _copyToChild: function(model
) {
497 _
.each(this.rels
, function(relValue
, relKey
) {
498 model
.setRel(relKey
, relValue
, {silent
: true});
501 setRel: function(key
, value
, options
) {
502 this.rels
= this.rels
|| {};
503 if (this.rels
[key
] != value
) {
504 this.rels
[key
] = value
;
505 this.trigger("rel:" + key
, value
);
508 getRel: function(key
) {
509 return this.rels
? this.rels
[key
] : null;
514 CRM.Backbone.Form = Backbone.Form.extend({
515 validate: function() {
516 // Add support for form-level validators
517 var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
519 if (this.validators) {
520 _.each(this.validators, function(validator) {
521 var modelErrors = validator(this.getValue());
523 // The following if() has been copied-pasted from the parent's
524 // handling of model-validators. They are similar in that the errors are
525 // probably keyed by field names... but not necessarily, so we use _others
528 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
530 //If errors are not in object form then just store on the error object
532 errors._others = errors._others || [];
533 errors._others.push(modelErrors);
536 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
538 _.each(modelErrors, function(val, key) {
539 //Set error on field if there isn't one already
540 if (self.fields[key] && !errors[key]) {
541 self.fields[key].setError(val);
546 //Otherwise add to '_others' key
547 errors._others = errors._others || [];
550 errors._others.push(tmpErr);
558 return _.isEmpty(errors) ? null : errors;
563 // Wrap an optional error callback with a fallback error event.
564 var wrapError = function (model
, options
) {
565 var error
= options
.error
;
566 options
.error = function(resp
) {
567 if (error
) error(model
, resp
, optio
)
568 model
.trigger('error', model
, resp
, options
);