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': | |
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); |