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