Commit | Line | Data |
---|---|---|
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': | |
35 | CRM.api(model.crmEntityName, 'get', model.toCrmCriteria(), apiOptions); | |
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(); | |
42 | CRM.api(model.crmEntityName, 'replace', params, apiOptions); | |
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) { |
a0a0f1df | 77 | CRM.api(model.crmEntityName, 'create', params, apiOptions); |
f0e1e747 | 78 | } else { |
a0a0f1df | 79 | CRM.api(model.crmEntityName, '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 | } | |
005f3f4d | 90 | CRM.api(model.crmEntityName, 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, | |
119 | toCrmCriteria: function() { | |
120 | return (this.get('id')) ? {id: this.get('id')} : {}; | |
f0e1e747 TO |
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; | |
231a4c0f TO |
131 | } |
132 | }); | |
133 | // Overrides - if specified in ModelClass, replace | |
134 | _.extend(ModelClass.prototype, { | |
135 | sync: CRM.Backbone.sync | |
136 | }); | |
137 | }; | |
138 | ||
b488e159 TO |
139 | /** |
140 | * Configure a model class to track whether a model has unsaved changes. | |
141 | * | |
4630e5b5 TO |
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 | |
b488e159 TO |
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() { | |
a8ad539c | 176 | var result = !this.isNew() && !this.isModified(); |
b488e159 TO |
177 | return result; |
178 | }, | |
a8ad539c | 179 | isModified: function() { |
67aaa7a2 | 180 | return this._modified; |
a8ad539c | 181 | }, |
b488e159 TO |
182 | _saved_onchange: function(model, options) { |
183 | if (options.parse) return; | |
a8ad539c | 184 | // console.log('change', model.changedAttributes(), model.previousAttributes()); |
ce10e55a TO |
185 | this.setModified(); |
186 | }, | |
187 | setModified: function() { | |
b488e159 TO |
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 | ||
231a4c0f | 220 | /** |
ce10e55a TO |
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 | * | |
4630e5b5 TO |
226 | * Methods: |
227 | * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted) | |
228 | * isSoftDeleted() - determine whether model has been soft-deleted | |
ce10e55a | 229 | * Events: |
4630e5b5 | 230 | * softDelete(model, is_deleted) -- change value of is_deleted |
ce10e55a TO |
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 | /** | |
231a4c0f TO |
266 | * Connect a "collection" class to CiviCRM's APIv3 |
267 | * | |
268 | * Note: the collection supports a special property, crmCriteria, which is an array of | |
4630e5b5 | 269 | * query options to send to the API. |
231a4c0f TO |
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 | * | |
a449b17d | 280 | * // Use class (with passive criteria) |
231a4c0f TO |
281 | * var c = new ContactCollection([], { |
282 | * crmCriteria: {contact_type: 'Organization'} | |
283 | * }); | |
284 | * c.fetch(); | |
4630e5b5 TO |
285 | * c.get(123).set('property', 'value'); |
286 | * c.get(456).setDeleted(true); | |
287 | * c.save(); | |
a449b17d TO |
288 | * |
289 | * // Use class (with active criteria) | |
290 | * var criteriaModel = new SomeModel({ | |
291 | * contact_type: 'Organization' | |
292 | * }); | |
293 | * var c = new ContactCollection([], { | |
294 | * crmCriteriaModel: criteriaModel | |
295 | * }); | |
296 | * c.fetch(); | |
297 | * c.get(123).set('property', 'value'); | |
298 | * c.get(456).setDeleted(true); | |
299 | * c.save(); | |
231a4c0f TO |
300 | * @endcode |
301 | * | |
a449b17d | 302 | * |
231a4c0f | 303 | * @param Class CollectionClass |
e8b1bc2a | 304 | * @see tests/qunit/crm-backbone |
231a4c0f TO |
305 | */ |
306 | CRM.Backbone.extendCollection = function(CollectionClass) { | |
307 | var origInit = CollectionClass.prototype.initialize; | |
308 | // Defaults - if specified in CollectionClass, preserve | |
309 | _.defaults(CollectionClass.prototype, { | |
310 | crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName, | |
311 | toCrmCriteria: function() { | |
4630e5b5 TO |
312 | return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {}; |
313 | }, | |
314 | ||
a449b17d TO |
315 | /** |
316 | * Get an object which represents this collection's criteria | |
317 | * as a live model. Any changes to the model will be applied | |
318 | * to the collection, and the collection will be refreshed. | |
319 | * | |
320 | * @param criteriaModelClass | |
321 | */ | |
322 | setCriteriaModel: function(criteriaModel) { | |
323 | var collection = this; | |
324 | this.crmCriteria = criteriaModel.toJSON(); | |
325 | this.listenTo(criteriaModel, 'change', function() { | |
326 | collection.crmCriteria = criteriaModel.toJSON(); | |
327 | collection.debouncedFetch(); | |
328 | }); | |
329 | }, | |
330 | ||
331 | debouncedFetch: _.debounce(function() { | |
332 | this.fetch({reset: true}); | |
333 | }, 500), | |
334 | ||
4630e5b5 TO |
335 | /** |
336 | * Reconcile the server's collection with the client's collection. | |
337 | * New/modified items from the client will be saved/updated on the | |
338 | * server. Deleted items from the client will be deleted on the | |
339 | * server. | |
340 | * | |
341 | * @param Object options - accepts "success" and "error" callbacks | |
342 | */ | |
343 | save: function(options) { | |
344 | options || (options = {}); | |
345 | var collection = this; | |
346 | var success = options.success; | |
347 | options.success = function(resp) { | |
348 | // Ensure attributes are restored during synchronous saves. | |
349 | collection.reset(resp, options); | |
350 | if (success) success(collection, resp, options); | |
351 | // collection.trigger('sync', collection, resp, options); | |
352 | }; | |
353 | wrapError(collection, options); | |
354 | ||
355 | return this.sync('crm-replace', this, options) | |
231a4c0f TO |
356 | } |
357 | }); | |
358 | // Overrides - if specified in CollectionClass, replace | |
359 | _.extend(CollectionClass.prototype, { | |
360 | sync: CRM.Backbone.sync, | |
361 | initialize: function(models, options) { | |
362 | options || (options = {}); | |
a449b17d TO |
363 | if (options.crmCriteriaModel) { |
364 | this.setCriteriaModel(options.crmCriteriaModel); | |
365 | } else if (options.crmCriteria) { | |
231a4c0f TO |
366 | this.crmCriteria = options.crmCriteria; |
367 | } | |
368 | if (origInit) { | |
369 | return origInit.apply(this, arguments); | |
370 | } | |
4630e5b5 TO |
371 | }, |
372 | toJSON: function() { | |
373 | var result = []; | |
374 | // filter models list, excluding any soft-deleted items | |
375 | this.each(function(model) { | |
376 | // if model doesn't track soft-deletes | |
377 | // or if model tracks soft-deletes and wasn't soft-deleted | |
378 | if (!model.isSoftDeleted || !model.isSoftDeleted()) { | |
379 | result.push(model.toJSON()); | |
380 | } | |
381 | }); | |
382 | return result; | |
231a4c0f TO |
383 | } |
384 | }); | |
385 | }; | |
386 | ||
4bbb9761 TO |
387 | /** |
388 | * Find a single record, or create a new record. | |
389 | * | |
390 | * @param Object options: | |
391 | * - CollectionClass: class | |
392 | * - crmCriteria: Object values to search/default on | |
393 | * - defaults: Object values to put on newly created model (if needed) | |
394 | * - success: function(model) | |
395 | * - error: function(collection, error) | |
396 | */ | |
397 | CRM.Backbone.findCreate = function(options) { | |
398 | options || (options = {}); | |
399 | var collection = new options.CollectionClass([], { | |
400 | crmCriteria: options.crmCriteria | |
401 | }); | |
402 | collection.fetch({ | |
403 | success: function(collection) { | |
404 | if (collection.length == 0) { | |
405 | var attrs = _.extend({}, collection.crmCriteria, options.defaults || {}); | |
406 | var model = collection._prepareModel(attrs, options); | |
407 | options.success(model); | |
408 | } else if (collection.length == 1) { | |
409 | options.success(collection.first()); | |
410 | } else { | |
411 | options.error(collection, { | |
412 | is_error: 1, | |
413 | error_message: 'Too many matches' | |
414 | }); | |
415 | } | |
416 | }, | |
417 | error: function(collection, errorData) { | |
418 | if (options.error) { | |
419 | options.error(collection, errorData); | |
420 | } | |
421 | } | |
422 | }); | |
423 | }; | |
424 | ||
425 | ||
6a488035 TO |
426 | CRM.Backbone.Model = Backbone.Model.extend({ |
427 | /** | |
428 | * Return JSON version of model -- but only include fields that are | |
429 | * listed in the 'schema'. | |
430 | * | |
431 | * @return {*} | |
432 | */ | |
433 | toStrictJSON: function() { | |
434 | var schema = this.schema; | |
435 | var result = this.toJSON(); | |
e8b1bc2a TO |
436 | _.each(result, function(value, key) { |
437 | if (!schema[key]) { | |
6a488035 TO |
438 | delete result[key]; |
439 | } | |
440 | }); | |
441 | return result; | |
442 | }, | |
443 | setRel: function(key, value, options) { | |
444 | this.rels = this.rels || {}; | |
445 | if (this.rels[key] != value) { | |
446 | this.rels[key] = value; | |
e8b1bc2a | 447 | this.trigger("rel:" + key, value); |
6a488035 TO |
448 | } |
449 | }, | |
450 | getRel: function(key) { | |
451 | return this.rels ? this.rels[key] : null; | |
452 | } | |
453 | }); | |
454 | ||
455 | CRM.Backbone.Collection = Backbone.Collection.extend({ | |
456 | /** | |
457 | * Store 'key' on this.rel and automatically copy it to | |
458 | * any children. | |
459 | * | |
460 | * @param key | |
461 | * @param value | |
462 | * @param initialModels | |
463 | */ | |
464 | initializeCopyToChildrenRelation: function(key, value, initialModels) { | |
465 | this.setRel(key, value, {silent: true}); | |
466 | this.on('reset', this._copyToChildren, this); | |
467 | this.on('add', this._copyToChild, this); | |
468 | }, | |
469 | _copyToChildren: function() { | |
470 | var collection = this; | |
e8b1bc2a | 471 | collection.each(function(model) { |
6a488035 TO |
472 | collection._copyToChild(model); |
473 | }); | |
474 | }, | |
475 | _copyToChild: function(model) { | |
e8b1bc2a | 476 | _.each(this.rels, function(relValue, relKey) { |
6a488035 TO |
477 | model.setRel(relKey, relValue, {silent: true}); |
478 | }); | |
479 | }, | |
480 | setRel: function(key, value, options) { | |
481 | this.rels = this.rels || {}; | |
482 | if (this.rels[key] != value) { | |
483 | this.rels[key] = value; | |
e8b1bc2a | 484 | this.trigger("rel:" + key, value); |
6a488035 TO |
485 | } |
486 | }, | |
487 | getRel: function(key) { | |
488 | return this.rels ? this.rels[key] : null; | |
489 | } | |
490 | }); | |
491 | ||
492 | /* | |
493 | CRM.Backbone.Form = Backbone.Form.extend({ | |
494 | validate: function() { | |
495 | // Add support for form-level validators | |
496 | var errors = Backbone.Form.prototype.validate.apply(this, []) || {}; | |
497 | var self = this; | |
498 | if (this.validators) { | |
499 | _.each(this.validators, function(validator) { | |
500 | var modelErrors = validator(this.getValue()); | |
501 | ||
502 | // The following if() has been copied-pasted from the parent's | |
503 | // handling of model-validators. They are similar in that the errors are | |
504 | // probably keyed by field names... but not necessarily, so we use _others | |
505 | // as a fallback. | |
506 | if (modelErrors) { | |
507 | var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors); | |
508 | ||
509 | //If errors are not in object form then just store on the error object | |
510 | if (!isDictionary) { | |
511 | errors._others = errors._others || []; | |
512 | errors._others.push(modelErrors); | |
513 | } | |
514 | ||
515 | //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' }) | |
516 | if (isDictionary) { | |
517 | _.each(modelErrors, function(val, key) { | |
518 | //Set error on field if there isn't one already | |
519 | if (self.fields[key] && !errors[key]) { | |
520 | self.fields[key].setError(val); | |
521 | errors[key] = val; | |
522 | } | |
523 | ||
524 | else { | |
525 | //Otherwise add to '_others' key | |
526 | errors._others = errors._others || []; | |
527 | var tmpErr = {}; | |
528 | tmpErr[key] = val; | |
529 | errors._others.push(tmpErr); | |
530 | } | |
531 | }); | |
532 | } | |
533 | } | |
534 | ||
535 | }); | |
536 | } | |
537 | return _.isEmpty(errors) ? null : errors; | |
538 | } | |
539 | }); | |
540 | */ | |
4630e5b5 TO |
541 | |
542 | // Wrap an optional error callback with a fallback error event. | |
543 | var wrapError = function (model, options) { | |
544 | var error = options.error; | |
545 | options.error = function(resp) { | |
546 | if (error) error(model, resp, options); | |
547 | model.trigger('error', model, resp, options); | |
548 | }; | |
549 | }; | |
6a488035 | 550 | })(cj); |