2 var CRM
= (window
.CRM
) ? (window
.CRM
) : (window
.CRM
= {});
3 if (!CRM
.Backbone
) CRM
.Backbone
= {};
6 * Backbone.sync provider which uses CRM.api() for I/O.
7 * To support CRUD operations, model classes must be defined with a "crmEntityName" property.
8 * To load collections using API queries, set the "crmCriteria" property or override the
9 * method "toCrmCriteria".
11 * @param method Accepts normal Backbone.sync methods; also accepts "crm-replace"
14 * @see tests/qunit/crm-backbone
16 CRM
.Backbone
.sync = function(method
, model
, options
) {
17 var isCollection
= _
.isArray(model
.models
);
21 success: function(data
) {
23 options
.success(_
.toArray(data
.values
));
25 error: function(data
) {
26 // CRM.api displays errors by default, but Backbone.sync
27 // protocol requires us to override "error". This restores
28 // the default behavior.
29 $().crmError(data
.error_message
, ts('Error'));
35 CRM
.api(model
.crmEntityName
, 'get', model
.toCrmCriteria(), apiOptions
);
37 // replace all entities matching "x.crmCriteria" with new entities in "x.models"
39 var params
= this.toCrmCriteria();
41 params
.values
= this.toJSON();
42 CRM
.api(model
.crmEntityName
, 'replace', params
, apiOptions
);
45 apiOptions
.error({is_error
: 1, error_message
: "CRM.Backbone.sync(" + method
+ ") not implemented for collections"});
49 // callback options to pass to CRM.api
51 success: function(data
) {
53 var values
= _
.toArray(data
['values']);
54 if (data
.count
== 1) {
55 options
.success(values
[0]);
58 data
.error_message
= ts("Expected exactly one response");
59 apiOptions
.error(data
);
62 error: function(data
) {
63 // CRM.api displays errors by default, but Backbone.sync
64 // protocol requires us to override "error". This restores
65 // the default behavior.
66 $().crmError(data
.error_message
, ts('Error'));
71 case 'create': // pass-through
73 CRM
.api(model
.crmEntityName
, 'create', model
.toJSON(), apiOptions
);
77 var apiAction
= (method
== 'delete') ? 'delete' : 'get';
78 var params
= model
.toCrmCriteria();
80 apiOptions
.error({is_error
: 1, error_message
: 'Missing ID for ' + model
.crmEntityName
});
83 CRM
.api(model
.crmEntityName
, apiAction
, params
, apiOptions
);
86 apiOptions
.error({is_error
: 1, error_message
: "CRM.Backbone.sync(" + method
+ ") not implemented for models"});
92 * Connect a "model" class to CiviCRM's APIv3
96 * var ContactModel = Backbone.Model.extend({});
97 * CRM.Backbone.extendModel(ContactModel, "Contact");
100 * c = new ContactModel({id: 3});
104 * @param Class ModelClass
105 * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
106 * @see tests/qunit/crm-backbone
108 CRM
.Backbone
.extendModel = function(ModelClass
, crmEntityName
) {
109 // Defaults - if specified in ModelClass, preserve
110 _
.defaults(ModelClass
.prototype, {
111 crmEntityName
: crmEntityName
,
112 toCrmCriteria: function() {
113 return (this.get('id')) ? {id
: this.get('id')} : {};
116 // Overrides - if specified in ModelClass, replace
117 _
.extend(ModelClass
.prototype, {
118 sync
: CRM
.Backbone
.sync
123 * Configure a model class to track whether a model has unsaved changes.
126 * - setModified() - flag the model as modified/dirty
127 * - isSaved() - return true if there have been no changes to the data since the last fetch or save
129 * - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
131 * Note: You should not directly call isSaved() within the context of the success/error/sync callback;
132 * I haven't found a way to make isSaved() behave correctly within these callbacks without patching
133 * Backbone. Instead, attach an event listener to the 'saved' event.
137 CRM
.Backbone
.trackSaved = function(ModelClass
) {
138 // Retain references to some of the original class's functions
139 var Parent
= _
.pick(ModelClass
.prototype, 'initialize', 'save', 'fetch');
142 var onSyncSuccess = function() {
143 this._modified
= false;
144 if (this._oldModified
.length
> 0) {
145 this._oldModified
.pop();
147 this.trigger('saved', this, this.isSaved());
149 var onSaveError = function() {
150 if (this._oldModified
.length
> 0) {
151 this._modified
= this._oldModified
.pop();
152 this.trigger('saved', this, this.isSaved());
156 // Defaults - if specified in ModelClass, preserve
157 _
.defaults(ModelClass
.prototype, {
158 isSaved: function() {
159 var result
= !this.isNew() && !this._modified
;
162 _saved_onchange: function(model
, options
) {
163 if (options
.parse
) return;
166 setModified: function() {
167 var oldModified
= this._modified
;
168 this._modified
= true;
170 this.trigger('saved', this, this.isSaved());
175 // Overrides - if specified in ModelClass, replace
176 _
.extend(ModelClass
.prototype, {
177 initialize: function(options
) {
178 this._modified
= false;
179 this._oldModified
= [];
180 this.listenTo(this, 'change', this._saved_onchange
);
181 this.listenTo(this, 'error', onSaveError
);
182 this.listenTo(this, 'sync', onSyncSuccess
);
183 if (Parent
.initialize
) {
184 return Parent
.initialize
.apply(this, arguments
);
188 // we'll assume success
189 this._oldModified
.push(this._modified
);
190 return Parent
.save
.apply(this, arguments
);
193 this._oldModified
.push(this._modified
);
194 return Parent
.fetch
.apply(this, arguments
);
200 * Configure a model class to support client-side soft deletion.
201 * One can call "model.setDeleted(BOOLEAN)" to flag an entity for
202 * deletion (or not) -- however, deletion will be deferred until save()
206 * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
207 * isSoftDeleted() - determine whether model has been soft-deleted
209 * softDelete(model, is_deleted) -- change value of is_deleted
213 CRM
.Backbone
.trackSoftDelete = function(ModelClass
) {
214 // Retain references to some of the original class's functions
215 var Parent
= _
.pick(ModelClass
.prototype, 'save');
217 // Defaults - if specified in ModelClass, preserve
218 _
.defaults(ModelClass
.prototype, {
219 is_soft_deleted
: false,
220 setSoftDeleted: function(is_deleted
) {
221 if (this.is_soft_deleted
!= is_deleted
) {
222 this.is_soft_deleted
= is_deleted
;
223 this.trigger('softDelete', this, is_deleted
);
224 if (this.setModified
) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
227 isSoftDeleted: function() {
228 return this.is_soft_deleted
;
232 // Overrides - if specified in ModelClass, replace
233 _
.extend(ModelClass
.prototype, {
234 save: function(attributes
, options
) {
235 if (this.isSoftDeleted()) {
236 return this.destroy(options
);
238 return Parent
.save
.apply(this, arguments
);
245 * Connect a "collection" class to CiviCRM's APIv3
247 * Note: the collection supports a special property, crmCriteria, which is an array of
248 * query options to send to the API.
252 * var ContactModel = Backbone.Model.extend({});
253 * CRM.Backbone.extendModel(ContactModel, "Contact");
254 * var ContactCollection = Backbone.Collection.extend({
255 * model: ContactModel
257 * CRM.Backbone.extendCollection(ContactCollection);
260 * var c = new ContactCollection([], {
261 * crmCriteria: {contact_type: 'Organization'}
264 * c.get(123).set('property', 'value');
265 * c.get(456).setDeleted(true);
269 * @param Class CollectionClass
270 * @see tests/qunit/crm-backbone
272 CRM
.Backbone
.extendCollection = function(CollectionClass
) {
273 var origInit
= CollectionClass
.prototype.initialize
;
274 // Defaults - if specified in CollectionClass, preserve
275 _
.defaults(CollectionClass
.prototype, {
276 crmEntityName
: CollectionClass
.prototype.model
.prototype.crmEntityName
,
277 toCrmCriteria: function() {
278 return (this.crmCriteria
) ? _
.extend({}, this.crmCriteria
) : {};
282 * Reconcile the server's collection with the client's collection.
283 * New/modified items from the client will be saved/updated on the
284 * server. Deleted items from the client will be deleted on the
287 * @param Object options - accepts "success" and "error" callbacks
289 save: function(options
) {
290 options
|| (options
= {});
291 var collection
= this;
292 var success
= options
.success
;
293 options
.success = function(resp
) {
294 // Ensure attributes are restored during synchronous saves.
295 collection
.reset(resp
, options
);
296 if (success
) success(collection
, resp
, options
);
297 // collection.trigger('sync', collection, resp, options);
299 wrapError(collection
, options
);
301 return this.sync('crm-replace', this, options
)
304 // Overrides - if specified in CollectionClass, replace
305 _
.extend(CollectionClass
.prototype, {
306 sync
: CRM
.Backbone
.sync
,
307 initialize: function(models
, options
) {
308 options
|| (options
= {});
309 if (options
.crmCriteria
) {
310 this.crmCriteria
= options
.crmCriteria
;
313 return origInit
.apply(this, arguments
);
318 // filter models list, excluding any soft-deleted items
319 this.each(function(model
) {
320 // if model doesn't track soft-deletes
321 // or if model tracks soft-deletes and wasn't soft-deleted
322 if (!model
.isSoftDeleted
|| !model
.isSoftDeleted()) {
323 result
.push(model
.toJSON());
332 * Find a single record, or create a new record.
334 * @param Object options:
335 * - CollectionClass: class
336 * - crmCriteria: Object values to search/default on
337 * - defaults: Object values to put on newly created model (if needed)
338 * - success: function(model)
339 * - error: function(collection, error)
341 CRM
.Backbone
.findCreate = function(options
) {
342 options
|| (options
= {});
343 var collection
= new options
.CollectionClass([], {
344 crmCriteria
: options
.crmCriteria
347 success: function(collection
) {
348 if (collection
.length
== 0) {
349 var attrs
= _
.extend({}, collection
.crmCriteria
, options
.defaults
|| {});
350 var model
= collection
._prepareModel(attrs
, options
);
351 options
.success(model
);
352 } else if (collection
.length
== 1) {
353 options
.success(collection
.first());
355 options
.error(collection
, {
357 error_message
: 'Too many matches'
361 error: function(collection
, errorData
) {
363 options
.error(collection
, errorData
);
370 CRM
.Backbone
.Model
= Backbone
.Model
.extend({
372 * Return JSON version of model -- but only include fields that are
373 * listed in the 'schema'.
377 toStrictJSON: function() {
378 var schema
= this.schema
;
379 var result
= this.toJSON();
380 _
.each(result
, function(value
, key
) {
387 setRel: function(key
, value
, options
) {
388 this.rels
= this.rels
|| {};
389 if (this.rels
[key
] != value
) {
390 this.rels
[key
] = value
;
391 this.trigger("rel:" + key
, value
);
394 getRel: function(key
) {
395 return this.rels
? this.rels
[key
] : null;
399 CRM
.Backbone
.Collection
= Backbone
.Collection
.extend({
401 * Store 'key' on this.rel and automatically copy it to
406 * @param initialModels
408 initializeCopyToChildrenRelation: function(key
, value
, initialModels
) {
409 this.setRel(key
, value
, {silent
: true});
410 this.on('reset', this._copyToChildren
, this);
411 this.on('add', this._copyToChild
, this);
413 _copyToChildren: function() {
414 var collection
= this;
415 collection
.each(function(model
) {
416 collection
._copyToChild(model
);
419 _copyToChild: function(model
) {
420 _
.each(this.rels
, function(relValue
, relKey
) {
421 model
.setRel(relKey
, relValue
, {silent
: true});
424 setRel: function(key
, value
, options
) {
425 this.rels
= this.rels
|| {};
426 if (this.rels
[key
] != value
) {
427 this.rels
[key
] = value
;
428 this.trigger("rel:" + key
, value
);
431 getRel: function(key
) {
432 return this.rels
? this.rels
[key
] : null;
437 CRM.Backbone.Form = Backbone.Form.extend({
438 validate: function() {
439 // Add support for form-level validators
440 var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
442 if (this.validators) {
443 _.each(this.validators, function(validator) {
444 var modelErrors = validator(this.getValue());
446 // The following if() has been copied-pasted from the parent's
447 // handling of model-validators. They are similar in that the errors are
448 // probably keyed by field names... but not necessarily, so we use _others
451 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
453 //If errors are not in object form then just store on the error object
455 errors._others = errors._others || [];
456 errors._others.push(modelErrors);
459 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
461 _.each(modelErrors, function(val, key) {
462 //Set error on field if there isn't one already
463 if (self.fields[key] && !errors[key]) {
464 self.fields[key].setError(val);
469 //Otherwise add to '_others' key
470 errors._others = errors._others || [];
473 errors._others.push(tmpErr);
481 return _.isEmpty(errors) ? null : errors;
486 // Wrap an optional error callback with a fallback error event.
487 var wrapError = function (model
, options
) {
488 var error
= options
.error
;
489 options
.error = function(resp
) {
490 if (error
) error(model
, resp
, options
);
491 model
.trigger('error', model
, resp
, options
);