Merge pull request #1217 from samuelsov/CRM-10606
[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.isModified();
160 return result;
161 },
162 isModified: function() {
163 return this._modified;
164 },
165 _saved_onchange: function(model, options) {
166 if (options.parse) return;
167 // console.log('change', model.changedAttributes(), model.previousAttributes());
168 this.setModified();
169 },
170 setModified: function() {
171 var oldModified = this._modified;
172 this._modified = true;
173 if (!oldModified) {
174 this.trigger('saved', this, this.isSaved());
175 }
176 }
177 });
178
179 // Overrides - if specified in ModelClass, replace
180 _.extend(ModelClass.prototype, {
181 initialize: function(options) {
182 this._modified = false;
183 this._oldModified = [];
184 this.listenTo(this, 'change', this._saved_onchange);
185 this.listenTo(this, 'error', onSaveError);
186 this.listenTo(this, 'sync', onSyncSuccess);
187 if (Parent.initialize) {
188 return Parent.initialize.apply(this, arguments);
189 }
190 },
191 save: function() {
192 // we'll assume success
193 this._oldModified.push(this._modified);
194 return Parent.save.apply(this, arguments);
195 },
196 fetch: function() {
197 this._oldModified.push(this._modified);
198 return Parent.fetch.apply(this, arguments);
199 }
200 });
201 };
202
203 /**
204 * Configure a model class to support client-side soft deletion.
205 * One can call "model.setDeleted(BOOLEAN)" to flag an entity for
206 * deletion (or not) -- however, deletion will be deferred until save()
207 * is called.
208 *
209 * Methods:
210 * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
211 * isSoftDeleted() - determine whether model has been soft-deleted
212 * Events:
213 * softDelete(model, is_deleted) -- change value of is_deleted
214 *
215 * @param ModelClass
216 */
217 CRM.Backbone.trackSoftDelete = function(ModelClass) {
218 // Retain references to some of the original class's functions
219 var Parent = _.pick(ModelClass.prototype, 'save');
220
221 // Defaults - if specified in ModelClass, preserve
222 _.defaults(ModelClass.prototype, {
223 is_soft_deleted: false,
224 setSoftDeleted: function(is_deleted) {
225 if (this.is_soft_deleted != is_deleted) {
226 this.is_soft_deleted = is_deleted;
227 this.trigger('softDelete', this, is_deleted);
228 if (this.setModified) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
229 }
230 },
231 isSoftDeleted: function() {
232 return this.is_soft_deleted;
233 }
234 });
235
236 // Overrides - if specified in ModelClass, replace
237 _.extend(ModelClass.prototype, {
238 save: function(attributes, options) {
239 if (this.isSoftDeleted()) {
240 return this.destroy(options);
241 } else {
242 return Parent.save.apply(this, arguments);
243 }
244 }
245 });
246 };
247
248 /**
249 * Connect a "collection" class to CiviCRM's APIv3
250 *
251 * Note: the collection supports a special property, crmCriteria, which is an array of
252 * query options to send to the API.
253 *
254 * @code
255 * // Setup class
256 * var ContactModel = Backbone.Model.extend({});
257 * CRM.Backbone.extendModel(ContactModel, "Contact");
258 * var ContactCollection = Backbone.Collection.extend({
259 * model: ContactModel
260 * });
261 * CRM.Backbone.extendCollection(ContactCollection);
262 *
263 * // Use class
264 * var c = new ContactCollection([], {
265 * crmCriteria: {contact_type: 'Organization'}
266 * });
267 * c.fetch();
268 * c.get(123).set('property', 'value');
269 * c.get(456).setDeleted(true);
270 * c.save();
271 * @endcode
272 *
273 * @param Class CollectionClass
274 * @see tests/qunit/crm-backbone
275 */
276 CRM.Backbone.extendCollection = function(CollectionClass) {
277 var origInit = CollectionClass.prototype.initialize;
278 // Defaults - if specified in CollectionClass, preserve
279 _.defaults(CollectionClass.prototype, {
280 crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
281 toCrmCriteria: function() {
282 return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
283 },
284
285 /**
286 * Reconcile the server's collection with the client's collection.
287 * New/modified items from the client will be saved/updated on the
288 * server. Deleted items from the client will be deleted on the
289 * server.
290 *
291 * @param Object options - accepts "success" and "error" callbacks
292 */
293 save: function(options) {
294 options || (options = {});
295 var collection = this;
296 var success = options.success;
297 options.success = function(resp) {
298 // Ensure attributes are restored during synchronous saves.
299 collection.reset(resp, options);
300 if (success) success(collection, resp, options);
301 // collection.trigger('sync', collection, resp, options);
302 };
303 wrapError(collection, options);
304
305 return this.sync('crm-replace', this, options)
306 }
307 });
308 // Overrides - if specified in CollectionClass, replace
309 _.extend(CollectionClass.prototype, {
310 sync: CRM.Backbone.sync,
311 initialize: function(models, options) {
312 options || (options = {});
313 if (options.crmCriteria) {
314 this.crmCriteria = options.crmCriteria;
315 }
316 if (origInit) {
317 return origInit.apply(this, arguments);
318 }
319 },
320 toJSON: function() {
321 var result = [];
322 // filter models list, excluding any soft-deleted items
323 this.each(function(model) {
324 // if model doesn't track soft-deletes
325 // or if model tracks soft-deletes and wasn't soft-deleted
326 if (!model.isSoftDeleted || !model.isSoftDeleted()) {
327 result.push(model.toJSON());
328 }
329 });
330 return result;
331 }
332 });
333 };
334
335 /**
336 * Find a single record, or create a new record.
337 *
338 * @param Object options:
339 * - CollectionClass: class
340 * - crmCriteria: Object values to search/default on
341 * - defaults: Object values to put on newly created model (if needed)
342 * - success: function(model)
343 * - error: function(collection, error)
344 */
345 CRM.Backbone.findCreate = function(options) {
346 options || (options = {});
347 var collection = new options.CollectionClass([], {
348 crmCriteria: options.crmCriteria
349 });
350 collection.fetch({
351 success: function(collection) {
352 if (collection.length == 0) {
353 var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
354 var model = collection._prepareModel(attrs, options);
355 options.success(model);
356 } else if (collection.length == 1) {
357 options.success(collection.first());
358 } else {
359 options.error(collection, {
360 is_error: 1,
361 error_message: 'Too many matches'
362 });
363 }
364 },
365 error: function(collection, errorData) {
366 if (options.error) {
367 options.error(collection, errorData);
368 }
369 }
370 });
371 };
372
373
374 CRM.Backbone.Model = Backbone.Model.extend({
375 /**
376 * Return JSON version of model -- but only include fields that are
377 * listed in the 'schema'.
378 *
379 * @return {*}
380 */
381 toStrictJSON: function() {
382 var schema = this.schema;
383 var result = this.toJSON();
384 _.each(result, function(value, key) {
385 if (!schema[key]) {
386 delete result[key];
387 }
388 });
389 return result;
390 },
391 setRel: function(key, value, options) {
392 this.rels = this.rels || {};
393 if (this.rels[key] != value) {
394 this.rels[key] = value;
395 this.trigger("rel:" + key, value);
396 }
397 },
398 getRel: function(key) {
399 return this.rels ? this.rels[key] : null;
400 }
401 });
402
403 CRM.Backbone.Collection = Backbone.Collection.extend({
404 /**
405 * Store 'key' on this.rel and automatically copy it to
406 * any children.
407 *
408 * @param key
409 * @param value
410 * @param initialModels
411 */
412 initializeCopyToChildrenRelation: function(key, value, initialModels) {
413 this.setRel(key, value, {silent: true});
414 this.on('reset', this._copyToChildren, this);
415 this.on('add', this._copyToChild, this);
416 },
417 _copyToChildren: function() {
418 var collection = this;
419 collection.each(function(model) {
420 collection._copyToChild(model);
421 });
422 },
423 _copyToChild: function(model) {
424 _.each(this.rels, function(relValue, relKey) {
425 model.setRel(relKey, relValue, {silent: true});
426 });
427 },
428 setRel: function(key, value, options) {
429 this.rels = this.rels || {};
430 if (this.rels[key] != value) {
431 this.rels[key] = value;
432 this.trigger("rel:" + key, value);
433 }
434 },
435 getRel: function(key) {
436 return this.rels ? this.rels[key] : null;
437 }
438 });
439
440 /*
441 CRM.Backbone.Form = Backbone.Form.extend({
442 validate: function() {
443 // Add support for form-level validators
444 var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
445 var self = this;
446 if (this.validators) {
447 _.each(this.validators, function(validator) {
448 var modelErrors = validator(this.getValue());
449
450 // The following if() has been copied-pasted from the parent's
451 // handling of model-validators. They are similar in that the errors are
452 // probably keyed by field names... but not necessarily, so we use _others
453 // as a fallback.
454 if (modelErrors) {
455 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
456
457 //If errors are not in object form then just store on the error object
458 if (!isDictionary) {
459 errors._others = errors._others || [];
460 errors._others.push(modelErrors);
461 }
462
463 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
464 if (isDictionary) {
465 _.each(modelErrors, function(val, key) {
466 //Set error on field if there isn't one already
467 if (self.fields[key] && !errors[key]) {
468 self.fields[key].setError(val);
469 errors[key] = val;
470 }
471
472 else {
473 //Otherwise add to '_others' key
474 errors._others = errors._others || [];
475 var tmpErr = {};
476 tmpErr[key] = val;
477 errors._others.push(tmpErr);
478 }
479 });
480 }
481 }
482
483 });
484 }
485 return _.isEmpty(errors) ? null : errors;
486 }
487 });
488 */
489
490 // Wrap an optional error callback with a fallback error event.
491 var wrapError = function (model, options) {
492 var error = options.error;
493 options.error = function(resp) {
494 if (error) error(model, resp, options);
495 model.trigger('error', model, resp, options);
496 };
497 };
498 })(cj);