Merge remote-tracking branch 'upstream/4.3' into 4.3-master-2013-09-25-01-46-57
[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
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 * @endcode
289 *
290 * @param Class CollectionClass
291 * @see tests/qunit/crm-backbone
292 */
293 CRM.Backbone.extendCollection = function(CollectionClass) {
294 var origInit = CollectionClass.prototype.initialize;
295 // Defaults - if specified in CollectionClass, preserve
296 _.defaults(CollectionClass.prototype, {
297 crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
298 toCrmCriteria: function() {
299 return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
300 },
301
302 /**
303 * Reconcile the server's collection with the client's collection.
304 * New/modified items from the client will be saved/updated on the
305 * server. Deleted items from the client will be deleted on the
306 * server.
307 *
308 * @param Object options - accepts "success" and "error" callbacks
309 */
310 save: function(options) {
311 options || (options = {});
312 var collection = this;
313 var success = options.success;
314 options.success = function(resp) {
315 // Ensure attributes are restored during synchronous saves.
316 collection.reset(resp, options);
317 if (success) success(collection, resp, options);
318 // collection.trigger('sync', collection, resp, options);
319 };
320 wrapError(collection, options);
321
322 return this.sync('crm-replace', this, options)
323 }
324 });
325 // Overrides - if specified in CollectionClass, replace
326 _.extend(CollectionClass.prototype, {
327 sync: CRM.Backbone.sync,
328 initialize: function(models, options) {
329 options || (options = {});
330 if (options.crmCriteria) {
331 this.crmCriteria = options.crmCriteria;
332 }
333 if (origInit) {
334 return origInit.apply(this, arguments);
335 }
336 },
337 toJSON: function() {
338 var result = [];
339 // filter models list, excluding any soft-deleted items
340 this.each(function(model) {
341 // if model doesn't track soft-deletes
342 // or if model tracks soft-deletes and wasn't soft-deleted
343 if (!model.isSoftDeleted || !model.isSoftDeleted()) {
344 result.push(model.toJSON());
345 }
346 });
347 return result;
348 }
349 });
350 };
351
352 /**
353 * Find a single record, or create a new record.
354 *
355 * @param Object options:
356 * - CollectionClass: class
357 * - crmCriteria: Object values to search/default on
358 * - defaults: Object values to put on newly created model (if needed)
359 * - success: function(model)
360 * - error: function(collection, error)
361 */
362 CRM.Backbone.findCreate = function(options) {
363 options || (options = {});
364 var collection = new options.CollectionClass([], {
365 crmCriteria: options.crmCriteria
366 });
367 collection.fetch({
368 success: function(collection) {
369 if (collection.length == 0) {
370 var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
371 var model = collection._prepareModel(attrs, options);
372 options.success(model);
373 } else if (collection.length == 1) {
374 options.success(collection.first());
375 } else {
376 options.error(collection, {
377 is_error: 1,
378 error_message: 'Too many matches'
379 });
380 }
381 },
382 error: function(collection, errorData) {
383 if (options.error) {
384 options.error(collection, errorData);
385 }
386 }
387 });
388 };
389
390
391 CRM.Backbone.Model = Backbone.Model.extend({
392 /**
393 * Return JSON version of model -- but only include fields that are
394 * listed in the 'schema'.
395 *
396 * @return {*}
397 */
398 toStrictJSON: function() {
399 var schema = this.schema;
400 var result = this.toJSON();
401 _.each(result, function(value, key) {
402 if (!schema[key]) {
403 delete result[key];
404 }
405 });
406 return result;
407 },
408 setRel: function(key, value, options) {
409 this.rels = this.rels || {};
410 if (this.rels[key] != value) {
411 this.rels[key] = value;
412 this.trigger("rel:" + key, value);
413 }
414 },
415 getRel: function(key) {
416 return this.rels ? this.rels[key] : null;
417 }
418 });
419
420 CRM.Backbone.Collection = Backbone.Collection.extend({
421 /**
422 * Store 'key' on this.rel and automatically copy it to
423 * any children.
424 *
425 * @param key
426 * @param value
427 * @param initialModels
428 */
429 initializeCopyToChildrenRelation: function(key, value, initialModels) {
430 this.setRel(key, value, {silent: true});
431 this.on('reset', this._copyToChildren, this);
432 this.on('add', this._copyToChild, this);
433 },
434 _copyToChildren: function() {
435 var collection = this;
436 collection.each(function(model) {
437 collection._copyToChild(model);
438 });
439 },
440 _copyToChild: function(model) {
441 _.each(this.rels, function(relValue, relKey) {
442 model.setRel(relKey, relValue, {silent: true});
443 });
444 },
445 setRel: function(key, value, options) {
446 this.rels = this.rels || {};
447 if (this.rels[key] != value) {
448 this.rels[key] = value;
449 this.trigger("rel:" + key, value);
450 }
451 },
452 getRel: function(key) {
453 return this.rels ? this.rels[key] : null;
454 }
455 });
456
457 /*
458 CRM.Backbone.Form = Backbone.Form.extend({
459 validate: function() {
460 // Add support for form-level validators
461 var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
462 var self = this;
463 if (this.validators) {
464 _.each(this.validators, function(validator) {
465 var modelErrors = validator(this.getValue());
466
467 // The following if() has been copied-pasted from the parent's
468 // handling of model-validators. They are similar in that the errors are
469 // probably keyed by field names... but not necessarily, so we use _others
470 // as a fallback.
471 if (modelErrors) {
472 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
473
474 //If errors are not in object form then just store on the error object
475 if (!isDictionary) {
476 errors._others = errors._others || [];
477 errors._others.push(modelErrors);
478 }
479
480 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
481 if (isDictionary) {
482 _.each(modelErrors, function(val, key) {
483 //Set error on field if there isn't one already
484 if (self.fields[key] && !errors[key]) {
485 self.fields[key].setError(val);
486 errors[key] = val;
487 }
488
489 else {
490 //Otherwise add to '_others' key
491 errors._others = errors._others || [];
492 var tmpErr = {};
493 tmpErr[key] = val;
494 errors._others.push(tmpErr);
495 }
496 });
497 }
498 }
499
500 });
501 }
502 return _.isEmpty(errors) ? null : errors;
503 }
504 });
505 */
506
507 // Wrap an optional error callback with a fallback error event.
508 var wrapError = function (model, options) {
509 var error = options.error;
510 options.error = function(resp) {
511 if (error) error(model, resp, options);
512 model.trigger('error', model, resp, options);
513 };
514 };
515 })(cj);