Merge pull request #1214 from davecivicrm/CRM-12788
[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 CRM.api(model.crmEntityName, 'create', model.toJSON(), apiOptions);
74 break;
75 case 'read':
76 case 'delete':
77 var apiAction = (method == 'delete') ? 'delete' : 'get';
78 var params = model.toCrmCriteria();
79 if (!params.id) {
80 apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
81 return;
82 }
83 CRM.api(model.crmEntityName, apiAction, params, apiOptions);
84 break;
85 default:
86 apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
87 }
88 }
89 };
90
91 /**
92 * Connect a "model" class to CiviCRM's APIv3
93 *
94 * @code
95 * // Setup class
96 * var ContactModel = Backbone.Model.extend({});
97 * CRM.Backbone.extendModel(ContactModel, "Contact");
98 *
99 * // Use class
100 * c = new ContactModel({id: 3});
101 * c.fetch();
102 * @endcode
103 *
104 * @param Class ModelClass
105 * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
106 * @see tests/qunit/crm-backbone
107 */
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')} : {};
114 }
115 });
116 // Overrides - if specified in ModelClass, replace
117 _.extend(ModelClass.prototype, {
118 sync: CRM.Backbone.sync
119 });
120 };
121
122 /**
123 * Configure a model class to track whether a model has unsaved changes.
124 *
125 * Methods:
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
128 * Events:
129 * - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
130 *
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.
134 *
135 * @param ModelClass
136 */
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');
140
141 // Private callback
142 var onSyncSuccess = function() {
143 this._modified = false;
144 if (this._oldModified.length > 0) {
145 this._oldModified.pop();
146 }
147 this.trigger('saved', this, this.isSaved());
148 };
149 var onSaveError = function() {
150 if (this._oldModified.length > 0) {
151 this._modified = this._oldModified.pop();
152 this.trigger('saved', this, this.isSaved());
153 }
154 };
155
156 // Defaults - if specified in ModelClass, preserve
157 _.defaults(ModelClass.prototype, {
158 isSaved: function() {
159 var result = !this.isNew() && !this._modified;
160 return result;
161 },
162 _saved_onchange: function(model, options) {
163 if (options.parse) return;
164 this.setModified();
165 },
166 setModified: function() {
167 var oldModified = this._modified;
168 this._modified = true;
169 if (!oldModified) {
170 this.trigger('saved', this, this.isSaved());
171 }
172 }
173 });
174
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);
185 }
186 },
187 save: function() {
188 // we'll assume success
189 this._oldModified.push(this._modified);
190 return Parent.save.apply(this, arguments);
191 },
192 fetch: function() {
193 this._oldModified.push(this._modified);
194 return Parent.fetch.apply(this, arguments);
195 }
196 });
197 };
198
199 /**
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()
203 * is called.
204 *
205 * Methods:
206 * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
207 * isSoftDeleted() - determine whether model has been soft-deleted
208 * Events:
209 * softDelete(model, is_deleted) -- change value of is_deleted
210 *
211 * @param ModelClass
212 */
213 CRM.Backbone.trackSoftDelete = function(ModelClass) {
214 // Retain references to some of the original class's functions
215 var Parent = _.pick(ModelClass.prototype, 'save');
216
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
225 }
226 },
227 isSoftDeleted: function() {
228 return this.is_soft_deleted;
229 }
230 });
231
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);
237 } else {
238 return Parent.save.apply(this, arguments);
239 }
240 }
241 });
242 };
243
244 /**
245 * Connect a "collection" class to CiviCRM's APIv3
246 *
247 * Note: the collection supports a special property, crmCriteria, which is an array of
248 * query options to send to the API.
249 *
250 * @code
251 * // Setup class
252 * var ContactModel = Backbone.Model.extend({});
253 * CRM.Backbone.extendModel(ContactModel, "Contact");
254 * var ContactCollection = Backbone.Collection.extend({
255 * model: ContactModel
256 * });
257 * CRM.Backbone.extendCollection(ContactCollection);
258 *
259 * // Use class
260 * var c = new ContactCollection([], {
261 * crmCriteria: {contact_type: 'Organization'}
262 * });
263 * c.fetch();
264 * c.get(123).set('property', 'value');
265 * c.get(456).setDeleted(true);
266 * c.save();
267 * @endcode
268 *
269 * @param Class CollectionClass
270 * @see tests/qunit/crm-backbone
271 */
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) : {};
279 },
280
281 /**
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
285 * server.
286 *
287 * @param Object options - accepts "success" and "error" callbacks
288 */
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);
298 };
299 wrapError(collection, options);
300
301 return this.sync('crm-replace', this, options)
302 }
303 });
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;
311 }
312 if (origInit) {
313 return origInit.apply(this, arguments);
314 }
315 },
316 toJSON: function() {
317 var result = [];
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());
324 }
325 });
326 return result;
327 }
328 });
329 };
330
331 /**
332 * Find a single record, or create a new record.
333 *
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)
340 */
341 CRM.Backbone.findCreate = function(options) {
342 options || (options = {});
343 var collection = new options.CollectionClass([], {
344 crmCriteria: options.crmCriteria
345 });
346 collection.fetch({
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());
354 } else {
355 options.error(collection, {
356 is_error: 1,
357 error_message: 'Too many matches'
358 });
359 }
360 },
361 error: function(collection, errorData) {
362 if (options.error) {
363 options.error(collection, errorData);
364 }
365 }
366 });
367 };
368
369
370 CRM.Backbone.Model = Backbone.Model.extend({
371 /**
372 * Return JSON version of model -- but only include fields that are
373 * listed in the 'schema'.
374 *
375 * @return {*}
376 */
377 toStrictJSON: function() {
378 var schema = this.schema;
379 var result = this.toJSON();
380 _.each(result, function(value, key) {
381 if (!schema[key]) {
382 delete result[key];
383 }
384 });
385 return result;
386 },
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);
392 }
393 },
394 getRel: function(key) {
395 return this.rels ? this.rels[key] : null;
396 }
397 });
398
399 CRM.Backbone.Collection = Backbone.Collection.extend({
400 /**
401 * Store 'key' on this.rel and automatically copy it to
402 * any children.
403 *
404 * @param key
405 * @param value
406 * @param initialModels
407 */
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);
412 },
413 _copyToChildren: function() {
414 var collection = this;
415 collection.each(function(model) {
416 collection._copyToChild(model);
417 });
418 },
419 _copyToChild: function(model) {
420 _.each(this.rels, function(relValue, relKey) {
421 model.setRel(relKey, relValue, {silent: true});
422 });
423 },
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);
429 }
430 },
431 getRel: function(key) {
432 return this.rels ? this.rels[key] : null;
433 }
434 });
435
436 /*
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, []) || {};
441 var self = this;
442 if (this.validators) {
443 _.each(this.validators, function(validator) {
444 var modelErrors = validator(this.getValue());
445
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
449 // as a fallback.
450 if (modelErrors) {
451 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
452
453 //If errors are not in object form then just store on the error object
454 if (!isDictionary) {
455 errors._others = errors._others || [];
456 errors._others.push(modelErrors);
457 }
458
459 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
460 if (isDictionary) {
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);
465 errors[key] = val;
466 }
467
468 else {
469 //Otherwise add to '_others' key
470 errors._others = errors._others || [];
471 var tmpErr = {};
472 tmpErr[key] = val;
473 errors._others.push(tmpErr);
474 }
475 });
476 }
477 }
478
479 });
480 }
481 return _.isEmpty(errors) ? null : errors;
482 }
483 });
484 */
485
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);
492 };
493 };
494 })(cj);