Merge remote-tracking branch 'upstream/4.4' into 4.4-4.5-2014-10-14-11-16-10
[civicrm-core.git] / js / crm.backbone.js
1 (function($, _) {
2 if (!CRM.Backbone) CRM.Backbone = {};
3
4 /**
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".
9 *
10 * @param method Accepts normal Backbone.sync methods; also accepts "crm-replace"
11 * @param model
12 * @param options
13 * @see tests/qunit/crm-backbone
14 */
15 CRM.Backbone.sync = function(method, model, options) {
16 var isCollection = _.isArray(model.models);
17
18 if (isCollection) {
19 var apiOptions = {
20 success: function(data) {
21 // unwrap data
22 options.success(_.toArray(data.values));
23 },
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'));
29 options.error(data);
30 }
31 };
32 switch (method) {
33 case 'read':
34 CRM.api(model.crmEntityName, model.toCrmAction('get'), model.toCrmCriteria(), apiOptions);
35 break;
36 // replace all entities matching "x.crmCriteria" with new entities in "x.models"
37 case 'crm-replace':
38 var params = this.toCrmCriteria();
39 params.version = 3;
40 params.values = this.toJSON();
41 CRM.api(model.crmEntityName, model.toCrmAction('replace'), params, apiOptions);
42 break;
43 default:
44 apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
45 break;
46 }
47 } else {
48 // callback options to pass to CRM.api
49 var apiOptions = {
50 success: function(data) {
51 // unwrap data
52 var values = _.toArray(data['values']);
53 if (data.count == 1) {
54 options.success(values[0]);
55 } else {
56 data.is_error = 1;
57 data.error_message = ts("Expected exactly one response");
58 apiOptions.error(data);
59 }
60 },
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'));
66 options.error(data);
67 }
68 };
69 switch (method) {
70 case 'create': // pass-through
71 case 'update':
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);
77 } else {
78 CRM.api(model.crmEntityName, model.toCrmAction('duplicate'), params, apiOptions);
79 }
80 break;
81 case 'read':
82 case 'delete':
83 var apiAction = (method == 'delete') ? 'delete' : 'get';
84 var params = model.toCrmCriteria();
85 if (!params.id) {
86 apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
87 return;
88 }
89 CRM.api(model.crmEntityName, model.toCrmAction(apiAction), params, apiOptions);
90 break;
91 default:
92 apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
93 }
94 }
95 };
96
97 /**
98 * Connect a "model" class to CiviCRM's APIv3
99 *
100 * @code
101 * // Setup class
102 * var ContactModel = Backbone.Model.extend({});
103 * CRM.Backbone.extendModel(ContactModel, "Contact");
104 *
105 * // Use class
106 * c = new ContactModel({id: 3});
107 * c.fetch();
108 * @endcode
109 *
110 * @param Class ModelClass
111 * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
112 * @see tests/qunit/crm-backbone
113 */
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;
122 },
123 toCrmCriteria: function() {
124 var result = (this.get('id')) ? {id: this.get('id')} : {};
125 if (this.crmReturn != null) {
126 result.return = this.crmReturn;
127 }
128 return result;
129 },
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;
137 });
138 return newModel;
139 }
140 });
141 // Overrides - if specified in ModelClass, replace
142 _.extend(ModelClass.prototype, {
143 sync: CRM.Backbone.sync
144 });
145 };
146
147 /**
148 * Configure a model class to track whether a model has unsaved changes.
149 *
150 * Methods:
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
153 * Events:
154 * - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
155 *
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.
159 *
160 * @param ModelClass
161 */
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');
165
166 // Private callback
167 var onSyncSuccess = function() {
168 this._modified = false;
169 if (this._oldModified.length > 0) {
170 this._oldModified.pop();
171 }
172 this.trigger('saved', this, this.isSaved());
173 };
174 var onSaveError = function() {
175 if (this._oldModified.length > 0) {
176 this._modified = this._oldModified.pop();
177 this.trigger('saved', this, this.isSaved());
178 }
179 };
180
181 // Defaults - if specified in ModelClass, preserve
182 _.defaults(ModelClass.prototype, {
183 isSaved: function() {
184 var result = !this.isNew() && !this.isModified();
185 return result;
186 },
187 isModified: function() {
188 return this._modified;
189 },
190 _saved_onchange: function(model, options) {
191 if (options.parse) return;
192 // console.log('change', model.changedAttributes(), model.previousAttributes());
193 this.setModified();
194 },
195 setModified: function() {
196 var oldModified = this._modified;
197 this._modified = true;
198 if (!oldModified) {
199 this.trigger('saved', this, this.isSaved());
200 }
201 }
202 });
203
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);
214 }
215 },
216 save: function() {
217 // we'll assume success
218 this._oldModified.push(this._modified);
219 return Parent.save.apply(this, arguments);
220 },
221 fetch: function() {
222 this._oldModified.push(this._modified);
223 return Parent.fetch.apply(this, arguments);
224 }
225 });
226 };
227
228 /**
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()
232 * is called.
233 *
234 * Methods:
235 * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
236 * isSoftDeleted() - determine whether model has been soft-deleted
237 * Events:
238 * softDelete(model, is_deleted) -- change value of is_deleted
239 *
240 * @param ModelClass
241 */
242 CRM.Backbone.trackSoftDelete = function(ModelClass) {
243 // Retain references to some of the original class's functions
244 var Parent = _.pick(ModelClass.prototype, 'save');
245
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
254 }
255 },
256 isSoftDeleted: function() {
257 return this.is_soft_deleted;
258 }
259 });
260
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);
266 } else {
267 return Parent.save.apply(this, arguments);
268 }
269 }
270 });
271 };
272
273 /**
274 * Connect a "collection" class to CiviCRM's APIv3
275 *
276 * Note: the collection supports a special property, crmCriteria, which is an array of
277 * query options to send to the API.
278 *
279 * @code
280 * // Setup class
281 * var ContactModel = Backbone.Model.extend({});
282 * CRM.Backbone.extendModel(ContactModel, "Contact");
283 * var ContactCollection = Backbone.Collection.extend({
284 * model: ContactModel
285 * });
286 * CRM.Backbone.extendCollection(ContactCollection);
287 *
288 * // Use class (with passive criteria)
289 * var c = new ContactCollection([], {
290 * crmCriteria: {contact_type: 'Organization'}
291 * });
292 * c.fetch();
293 * c.get(123).set('property', 'value');
294 * c.get(456).setDeleted(true);
295 * c.save();
296 *
297 * // Use class (with active criteria)
298 * var criteriaModel = new SomeModel({
299 * contact_type: 'Organization'
300 * });
301 * var c = new ContactCollection([], {
302 * crmCriteriaModel: criteriaModel
303 * });
304 * c.fetch();
305 * c.get(123).set('property', 'value');
306 * c.get(456).setDeleted(true);
307 * c.save();
308 * @endcode
309 *
310 *
311 * @param Class CollectionClass
312 * @see tests/qunit/crm-backbone
313 */
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;
322 },
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;
329 }
330 return result;
331 },
332
333 /**
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.
337 *
338 * @param criteriaModelClass
339 */
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();
346 });
347 },
348
349 debouncedFetch: _.debounce(function() {
350 this.fetch({reset: true});
351 }, 100),
352
353 /**
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
357 * server.
358 *
359 * @param Object options - accepts "success" and "error" callbacks
360 */
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);
370 };
371 wrapError(collection, options);
372
373 return this.sync('crm-replace', this, options)
374 }
375 });
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;
385 }
386 if (options.crmActions) {
387 this.crmActions = _.extend(this.crmActions, options.crmActions);
388 }
389 if (origInit) {
390 return origInit.apply(this, arguments);
391 }
392 },
393 toJSON: function() {
394 var result = [];
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());
401 }
402 });
403 return result;
404 }
405 });
406 };
407
408 /**
409 * Find a single record, or create a new record.
410 *
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)
417 */
418 CRM.Backbone.findCreate = function(options) {
419 options || (options = {});
420 var collection = new options.CollectionClass([], {
421 crmCriteria: options.crmCriteria
422 });
423 collection.fetch({
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());
431 } else {
432 options.error(collection, {
433 is_error: 1,
434 error_message: 'Too many matches'
435 });
436 }
437 },
438 error: function(collection, errorData) {
439 if (options.error) {
440 options.error(collection, errorData);
441 }
442 }
443 });
444 };
445
446
447 CRM.Backbone.Model = Backbone.Model.extend({
448 /**
449 * Return JSON version of model -- but only include fields that are
450 * listed in the 'schema'.
451 *
452 * @return {*}
453 */
454 toStrictJSON: function() {
455 var schema = this.schema;
456 var result = this.toJSON();
457 _.each(result, function(value, key) {
458 if (!schema[key]) {
459 delete result[key];
460 }
461 });
462 return result;
463 },
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);
469 }
470 },
471 getRel: function(key) {
472 return this.rels ? this.rels[key] : null;
473 }
474 });
475
476 CRM.Backbone.Collection = Backbone.Collection.extend({
477 /**
478 * Store 'key' on this.rel and automatically copy it to
479 * any children.
480 *
481 * @param key
482 * @param value
483 * @param initialModels
484 */
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);
489 },
490 _copyToChildren: function() {
491 var collection = this;
492 collection.each(function(model) {
493 collection._copyToChild(model);
494 });
495 },
496 _copyToChild: function(model) {
497 _.each(this.rels, function(relValue, relKey) {
498 model.setRel(relKey, relValue, {silent: true});
499 });
500 },
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);
506 }
507 },
508 getRel: function(key) {
509 return this.rels ? this.rels[key] : null;
510 }
511 });
512
513 /*
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, []) || {};
518 var self = this;
519 if (this.validators) {
520 _.each(this.validators, function(validator) {
521 var modelErrors = validator(this.getValue());
522
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
526 // as a fallback.
527 if (modelErrors) {
528 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
529
530 //If errors are not in object form then just store on the error object
531 if (!isDictionary) {
532 errors._others = errors._others || [];
533 errors._others.push(modelErrors);
534 }
535
536 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
537 if (isDictionary) {
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);
542 errors[key] = val;
543 }
544
545 else {
546 //Otherwise add to '_others' key
547 errors._others = errors._others || [];
548 var tmpErr = {};
549 tmpErr[key] = val;
550 errors._others.push(tmpErr);
551 }
552 });
553 }
554 }
555
556 });
557 }
558 return _.isEmpty(errors) ? null : errors;
559 }
560 });
561 */
562
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);
569 };
570 };
571 })(CRM.$, CRM._);