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