Merge pull request #2246 from davecivicrm/CRM-13978
[civicrm-core.git] / js / crm.backbone.js
1 (function($) {
2 var CRM = (window.CRM) ? (window.CRM) : (window.CRM = {});
3 if (!CRM.Backbone) CRM.Backbone = {};
4
5 /**
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".
10 *
11 * @param method Accepts normal Backbone.sync methods; also accepts "crm-replace"
12 * @param model
13 * @param options
14 * @see tests/qunit/crm-backbone
15 */
16 CRM.Backbone.sync = function(method, model, options) {
17 var isCollection = _.isArray(model.models);
18
19 if (isCollection) {
20 var apiOptions = {
21 success: function(data) {
22 // unwrap data
23 options.success(_.toArray(data.values));
24 },
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'));
30 options.error(data);
31 }
32 };
33 switch (method) {
34 case 'read':
35 CRM.api(model.crmEntityName, 'get', model.toCrmCriteria(), apiOptions);
36 break;
37 // replace all entities matching "x.crmCriteria" with new entities in "x.models"
38 case 'crm-replace':
39 var params = this.toCrmCriteria();
40 params.version = 3;
41 params.values = this.toJSON();
42 CRM.api(model.crmEntityName, 'replace', params, apiOptions);
43 break;
44 default:
45 apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
46 break;
47 }
48 } else {
49 // callback options to pass to CRM.api
50 var apiOptions = {
51 success: function(data) {
52 // unwrap data
53 var values = _.toArray(data['values']);
54 if (data.count == 1) {
55 options.success(values[0]);
56 } else {
57 data.is_error = 1;
58 data.error_message = ts("Expected exactly one response");
59 apiOptions.error(data);
60 }
61 },
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'));
67 options.error(data);
68 }
69 };
70 switch (method) {
71 case 'create': // pass-through
72 case 'update':
73 var params = model.toJSON();
74 params.options || (params.options = {});
75 params.options.reload = 1;
76 if (!model._isDuplicate) {
77 CRM.api(model.crmEntityName, 'create', params, apiOptions);
78 } else {
79 CRM.api(model.crmEntityName, 'duplicate', params, apiOptions);
80 }
81 break;
82 case 'read':
83 case 'delete':
84 var apiAction = (method == 'delete') ? 'delete' : 'get';
85 var params = model.toCrmCriteria();
86 if (!params.id) {
87 apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
88 return;
89 }
90 CRM.api(model.crmEntityName, apiAction, params, apiOptions);
91 break;
92 default:
93 apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
94 }
95 }
96 };
97
98 /**
99 * Connect a "model" class to CiviCRM's APIv3
100 *
101 * @code
102 * // Setup class
103 * var ContactModel = Backbone.Model.extend({});
104 * CRM.Backbone.extendModel(ContactModel, "Contact");
105 *
106 * // Use class
107 * c = new ContactModel({id: 3});
108 * c.fetch();
109 * @endcode
110 *
111 * @param Class ModelClass
112 * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
113 * @see tests/qunit/crm-backbone
114 */
115 CRM.Backbone.extendModel = function(ModelClass, crmEntityName) {
116 // Defaults - if specified in ModelClass, preserve
117 _.defaults(ModelClass.prototype, {
118 crmEntityName: crmEntityName,
119 toCrmCriteria: function() {
120 return (this.get('id')) ? {id: this.get('id')} : {};
121 },
122 duplicate: function() {
123 var newModel = new ModelClass(this.toJSON());
124 newModel._isDuplicate = true;
125 if (newModel.setModified) newModel.setModified();
126 newModel.listenTo(newModel, 'sync', function(){
127 // may get called on subsequent resaves -- don't care!
128 delete newModel._isDuplicate;
129 });
130 return newModel;
131 }
132 });
133 // Overrides - if specified in ModelClass, replace
134 _.extend(ModelClass.prototype, {
135 sync: CRM.Backbone.sync
136 });
137 };
138
139 /**
140 * Configure a model class to track whether a model has unsaved changes.
141 *
142 * Methods:
143 * - setModified() - flag the model as modified/dirty
144 * - isSaved() - return true if there have been no changes to the data since the last fetch or save
145 * Events:
146 * - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
147 *
148 * Note: You should not directly call isSaved() within the context of the success/error/sync callback;
149 * I haven't found a way to make isSaved() behave correctly within these callbacks without patching
150 * Backbone. Instead, attach an event listener to the 'saved' event.
151 *
152 * @param ModelClass
153 */
154 CRM.Backbone.trackSaved = function(ModelClass) {
155 // Retain references to some of the original class's functions
156 var Parent = _.pick(ModelClass.prototype, 'initialize', 'save', 'fetch');
157
158 // Private callback
159 var onSyncSuccess = function() {
160 this._modified = false;
161 if (this._oldModified.length > 0) {
162 this._oldModified.pop();
163 }
164 this.trigger('saved', this, this.isSaved());
165 };
166 var onSaveError = function() {
167 if (this._oldModified.length > 0) {
168 this._modified = this._oldModified.pop();
169 this.trigger('saved', this, this.isSaved());
170 }
171 };
172
173 // Defaults - if specified in ModelClass, preserve
174 _.defaults(ModelClass.prototype, {
175 isSaved: function() {
176 var result = !this.isNew() && !this.isModified();
177 return result;
178 },
179 isModified: function() {
180 return this._modified;
181 },
182 _saved_onchange: function(model, options) {
183 if (options.parse) return;
184 // console.log('change', model.changedAttributes(), model.previousAttributes());
185 this.setModified();
186 },
187 setModified: function() {
188 var oldModified = this._modified;
189 this._modified = true;
190 if (!oldModified) {
191 this.trigger('saved', this, this.isSaved());
192 }
193 }
194 });
195
196 // Overrides - if specified in ModelClass, replace
197 _.extend(ModelClass.prototype, {
198 initialize: function(options) {
199 this._modified = false;
200 this._oldModified = [];
201 this.listenTo(this, 'change', this._saved_onchange);
202 this.listenTo(this, 'error', onSaveError);
203 this.listenTo(this, 'sync', onSyncSuccess);
204 if (Parent.initialize) {
205 return Parent.initialize.apply(this, arguments);
206 }
207 },
208 save: function() {
209 // we'll assume success
210 this._oldModified.push(this._modified);
211 return Parent.save.apply(this, arguments);
212 },
213 fetch: function() {
214 this._oldModified.push(this._modified);
215 return Parent.fetch.apply(this, arguments);
216 }
217 });
218 };
219
220 /**
221 * Configure a model class to support client-side soft deletion.
222 * One can call "model.setDeleted(BOOLEAN)" to flag an entity for
223 * deletion (or not) -- however, deletion will be deferred until save()
224 * is called.
225 *
226 * Methods:
227 * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
228 * isSoftDeleted() - determine whether model has been soft-deleted
229 * Events:
230 * softDelete(model, is_deleted) -- change value of is_deleted
231 *
232 * @param ModelClass
233 */
234 CRM.Backbone.trackSoftDelete = function(ModelClass) {
235 // Retain references to some of the original class's functions
236 var Parent = _.pick(ModelClass.prototype, 'save');
237
238 // Defaults - if specified in ModelClass, preserve
239 _.defaults(ModelClass.prototype, {
240 is_soft_deleted: false,
241 setSoftDeleted: function(is_deleted) {
242 if (this.is_soft_deleted != is_deleted) {
243 this.is_soft_deleted = is_deleted;
244 this.trigger('softDelete', this, is_deleted);
245 if (this.setModified) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
246 }
247 },
248 isSoftDeleted: function() {
249 return this.is_soft_deleted;
250 }
251 });
252
253 // Overrides - if specified in ModelClass, replace
254 _.extend(ModelClass.prototype, {
255 save: function(attributes, options) {
256 if (this.isSoftDeleted()) {
257 return this.destroy(options);
258 } else {
259 return Parent.save.apply(this, arguments);
260 }
261 }
262 });
263 };
264
265 /**
266 * Connect a "collection" class to CiviCRM's APIv3
267 *
268 * Note: the collection supports a special property, crmCriteria, which is an array of
269 * query options to send to the API.
270 *
271 * @code
272 * // Setup class
273 * var ContactModel = Backbone.Model.extend({});
274 * CRM.Backbone.extendModel(ContactModel, "Contact");
275 * var ContactCollection = Backbone.Collection.extend({
276 * model: ContactModel
277 * });
278 * CRM.Backbone.extendCollection(ContactCollection);
279 *
280 * // Use class (with passive criteria)
281 * var c = new ContactCollection([], {
282 * crmCriteria: {contact_type: 'Organization'}
283 * });
284 * c.fetch();
285 * c.get(123).set('property', 'value');
286 * c.get(456).setDeleted(true);
287 * c.save();
288 *
289 * // Use class (with active criteria)
290 * var criteriaModel = new SomeModel({
291 * contact_type: 'Organization'
292 * });
293 * var c = new ContactCollection([], {
294 * crmCriteriaModel: criteriaModel
295 * });
296 * c.fetch();
297 * c.get(123).set('property', 'value');
298 * c.get(456).setDeleted(true);
299 * c.save();
300 * @endcode
301 *
302 *
303 * @param Class CollectionClass
304 * @see tests/qunit/crm-backbone
305 */
306 CRM.Backbone.extendCollection = function(CollectionClass) {
307 var origInit = CollectionClass.prototype.initialize;
308 // Defaults - if specified in CollectionClass, preserve
309 _.defaults(CollectionClass.prototype, {
310 crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
311 toCrmCriteria: function() {
312 return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
313 },
314
315 /**
316 * Get an object which represents this collection's criteria
317 * as a live model. Any changes to the model will be applied
318 * to the collection, and the collection will be refreshed.
319 *
320 * @param criteriaModelClass
321 */
322 setCriteriaModel: function(criteriaModel) {
323 var collection = this;
324 this.crmCriteria = criteriaModel.toJSON();
325 this.listenTo(criteriaModel, 'change', function() {
326 collection.crmCriteria = criteriaModel.toJSON();
327 collection.debouncedFetch();
328 });
329 },
330
331 debouncedFetch: _.debounce(function() {
332 this.fetch({reset: true});
333 }, 500),
334
335 /**
336 * Reconcile the server's collection with the client's collection.
337 * New/modified items from the client will be saved/updated on the
338 * server. Deleted items from the client will be deleted on the
339 * server.
340 *
341 * @param Object options - accepts "success" and "error" callbacks
342 */
343 save: function(options) {
344 options || (options = {});
345 var collection = this;
346 var success = options.success;
347 options.success = function(resp) {
348 // Ensure attributes are restored during synchronous saves.
349 collection.reset(resp, options);
350 if (success) success(collection, resp, options);
351 // collection.trigger('sync', collection, resp, options);
352 };
353 wrapError(collection, options);
354
355 return this.sync('crm-replace', this, options)
356 }
357 });
358 // Overrides - if specified in CollectionClass, replace
359 _.extend(CollectionClass.prototype, {
360 sync: CRM.Backbone.sync,
361 initialize: function(models, options) {
362 options || (options = {});
363 if (options.crmCriteriaModel) {
364 this.setCriteriaModel(options.crmCriteriaModel);
365 } else if (options.crmCriteria) {
366 this.crmCriteria = options.crmCriteria;
367 }
368 if (origInit) {
369 return origInit.apply(this, arguments);
370 }
371 },
372 toJSON: function() {
373 var result = [];
374 // filter models list, excluding any soft-deleted items
375 this.each(function(model) {
376 // if model doesn't track soft-deletes
377 // or if model tracks soft-deletes and wasn't soft-deleted
378 if (!model.isSoftDeleted || !model.isSoftDeleted()) {
379 result.push(model.toJSON());
380 }
381 });
382 return result;
383 }
384 });
385 };
386
387 /**
388 * Find a single record, or create a new record.
389 *
390 * @param Object options:
391 * - CollectionClass: class
392 * - crmCriteria: Object values to search/default on
393 * - defaults: Object values to put on newly created model (if needed)
394 * - success: function(model)
395 * - error: function(collection, error)
396 */
397 CRM.Backbone.findCreate = function(options) {
398 options || (options = {});
399 var collection = new options.CollectionClass([], {
400 crmCriteria: options.crmCriteria
401 });
402 collection.fetch({
403 success: function(collection) {
404 if (collection.length == 0) {
405 var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
406 var model = collection._prepareModel(attrs, options);
407 options.success(model);
408 } else if (collection.length == 1) {
409 options.success(collection.first());
410 } else {
411 options.error(collection, {
412 is_error: 1,
413 error_message: 'Too many matches'
414 });
415 }
416 },
417 error: function(collection, errorData) {
418 if (options.error) {
419 options.error(collection, errorData);
420 }
421 }
422 });
423 };
424
425
426 CRM.Backbone.Model = Backbone.Model.extend({
427 /**
428 * Return JSON version of model -- but only include fields that are
429 * listed in the 'schema'.
430 *
431 * @return {*}
432 */
433 toStrictJSON: function() {
434 var schema = this.schema;
435 var result = this.toJSON();
436 _.each(result, function(value, key) {
437 if (!schema[key]) {
438 delete result[key];
439 }
440 });
441 return result;
442 },
443 setRel: function(key, value, options) {
444 this.rels = this.rels || {};
445 if (this.rels[key] != value) {
446 this.rels[key] = value;
447 this.trigger("rel:" + key, value);
448 }
449 },
450 getRel: function(key) {
451 return this.rels ? this.rels[key] : null;
452 }
453 });
454
455 CRM.Backbone.Collection = Backbone.Collection.extend({
456 /**
457 * Store 'key' on this.rel and automatically copy it to
458 * any children.
459 *
460 * @param key
461 * @param value
462 * @param initialModels
463 */
464 initializeCopyToChildrenRelation: function(key, value, initialModels) {
465 this.setRel(key, value, {silent: true});
466 this.on('reset', this._copyToChildren, this);
467 this.on('add', this._copyToChild, this);
468 },
469 _copyToChildren: function() {
470 var collection = this;
471 collection.each(function(model) {
472 collection._copyToChild(model);
473 });
474 },
475 _copyToChild: function(model) {
476 _.each(this.rels, function(relValue, relKey) {
477 model.setRel(relKey, relValue, {silent: true});
478 });
479 },
480 setRel: function(key, value, options) {
481 this.rels = this.rels || {};
482 if (this.rels[key] != value) {
483 this.rels[key] = value;
484 this.trigger("rel:" + key, value);
485 }
486 },
487 getRel: function(key) {
488 return this.rels ? this.rels[key] : null;
489 }
490 });
491
492 /*
493 CRM.Backbone.Form = Backbone.Form.extend({
494 validate: function() {
495 // Add support for form-level validators
496 var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
497 var self = this;
498 if (this.validators) {
499 _.each(this.validators, function(validator) {
500 var modelErrors = validator(this.getValue());
501
502 // The following if() has been copied-pasted from the parent's
503 // handling of model-validators. They are similar in that the errors are
504 // probably keyed by field names... but not necessarily, so we use _others
505 // as a fallback.
506 if (modelErrors) {
507 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
508
509 //If errors are not in object form then just store on the error object
510 if (!isDictionary) {
511 errors._others = errors._others || [];
512 errors._others.push(modelErrors);
513 }
514
515 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
516 if (isDictionary) {
517 _.each(modelErrors, function(val, key) {
518 //Set error on field if there isn't one already
519 if (self.fields[key] && !errors[key]) {
520 self.fields[key].setError(val);
521 errors[key] = val;
522 }
523
524 else {
525 //Otherwise add to '_others' key
526 errors._others = errors._others || [];
527 var tmpErr = {};
528 tmpErr[key] = val;
529 errors._others.push(tmpErr);
530 }
531 });
532 }
533 }
534
535 });
536 }
537 return _.isEmpty(errors) ? null : errors;
538 }
539 });
540 */
541
542 // Wrap an optional error callback with a fallback error event.
543 var wrapError = function (model, options) {
544 var error = options.error;
545 options.error = function(resp) {
546 if (error) error(model, resp, options);
547 model.trigger('error', model, resp, options);
548 };
549 };
550 })(cj);