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