9d5e2cba7e322cfeac00ce0ef585086058c1215e
[civicrm-core.git] / js / crm.backbone.js
1 (function($) {
2 var CRM = (window.CRM) ? (window.CRM) : (window.CRM = {});
3 if (!CRM.Backbone) CRM.Backbone = {};
4
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 *
11 * @param method
12 * @param model
13 * @param options
14 * @see tests/qunit/crm-backbone
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;
37 default:
38 apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
39 break;
40 }
41 } else {
42 // callback options to pass to CRM.api
43 var apiOptions = {
44 success: function(data) {
45 // unwrap data
46 var values = _.toArray(data['values']);
47 if (data.count == 1) {
48 options.success(values[0]);
49 } else {
50 data.is_error = 1;
51 data.error_message = ts("Expected exactly one response");
52 apiOptions.error(data);
53 }
54 },
55 error: function(data) {
56 // CRM.api displays errors by default, but Backbone.sync
57 // protocol requires us to override "error". This restores
58 // the default behavior.
59 $().crmError(data.error_message, ts('Error'));
60 options.error(data);
61 }
62 };
63 switch (method) {
64 case 'create': // pass-through
65 case 'update':
66 CRM.api(model.crmEntityName, 'create', model.toJSON(), apiOptions);
67 break;
68 case 'read':
69 case 'delete':
70 var apiAction = (method == 'delete') ? 'delete' : 'get';
71 var params = model.toCrmCriteria();
72 if (!params.id) {
73 apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
74 return;
75 }
76 CRM.api(model.crmEntityName, apiAction, params, apiOptions);
77 break;
78 default:
79 apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
80 }
81 }
82 };
83
84 /**
85 * Connect a "model" class to CiviCRM's APIv3
86 *
87 * @code
88 * // Setup class
89 * var ContactModel = Backbone.Model.extend({});
90 * CRM.Backbone.extendModel(ContactModel, "Contact");
91 *
92 * // Use class
93 * c = new ContactModel({id: 3});
94 * c.fetch();
95 * @endcode
96 *
97 * @param Class ModelClass
98 * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
99 * @see tests/qunit/crm-backbone
100 */
101 CRM.Backbone.extendModel = function(ModelClass, crmEntityName) {
102 // Defaults - if specified in ModelClass, preserve
103 _.defaults(ModelClass.prototype, {
104 crmEntityName: crmEntityName,
105 toCrmCriteria: function() {
106 return (this.get('id')) ? {id: this.get('id')} : {};
107 }
108 });
109 // Overrides - if specified in ModelClass, replace
110 _.extend(ModelClass.prototype, {
111 sync: CRM.Backbone.sync
112 });
113 };
114
115 /**
116 * Configure a model class to track whether a model has unsaved changes.
117 *
118 * The ModelClass will be extended with:
119 * - Method: isSaved() - true if there have been no changes to the data since the last fetch or save
120 * - Event: saved(object model, bool is_saved) - triggered whenever isSaved() value would change
121 *
122 * Note: You should not directly call isSaved() within the context of the success/error/sync callback;
123 * I haven't found a way to make isSaved() behave correctly within these callbacks without patching
124 * Backbone. Instead, attach an event listener to the 'saved' event.
125 *
126 * @param ModelClass
127 */
128 CRM.Backbone.trackSaved = function(ModelClass) {
129 // Retain references to some of the original class's functions
130 var Parent = _.pick(ModelClass.prototype, 'initialize', 'save', 'fetch');
131
132 // Private callback
133 var onSyncSuccess = function() {
134 this._modified = false;
135 if (this._oldModified.length > 0) {
136 this._oldModified.pop();
137 }
138 this.trigger('saved', this, this.isSaved());
139 };
140 var onSaveError = function() {
141 if (this._oldModified.length > 0) {
142 this._modified = this._oldModified.pop();
143 this.trigger('saved', this, this.isSaved());
144 }
145 };
146
147 // Defaults - if specified in ModelClass, preserve
148 _.defaults(ModelClass.prototype, {
149 isSaved: function() {
150 var result = !this.isNew() && !this._modified;
151 return result;
152 },
153 _saved_onchange: function(model, options) {
154 if (options.parse) return;
155 this.setModified();
156 },
157 setModified: function() {
158 var oldModified = this._modified;
159 this._modified = true;
160 if (!oldModified) {
161 this.trigger('saved', this, this.isSaved());
162 }
163 }
164 });
165
166 // Overrides - if specified in ModelClass, replace
167 _.extend(ModelClass.prototype, {
168 initialize: function(options) {
169 this._modified = false;
170 this._oldModified = [];
171 this.listenTo(this, 'change', this._saved_onchange);
172 this.listenTo(this, 'error', onSaveError);
173 this.listenTo(this, 'sync', onSyncSuccess);
174 if (Parent.initialize) {
175 return Parent.initialize.apply(this, arguments);
176 }
177 },
178 save: function() {
179 // we'll assume success
180 this._oldModified.push(this._modified);
181 return Parent.save.apply(this, arguments);
182 },
183 fetch: function() {
184 this._oldModified.push(this._modified);
185 return Parent.fetch.apply(this, arguments);
186 }
187 });
188 };
189
190 /**
191 * Configure a model class to support client-side soft deletion.
192 * One can call "model.setDeleted(BOOLEAN)" to flag an entity for
193 * deletion (or not) -- however, deletion will be deferred until save()
194 * is called.
195 *
196 * Events:
197 * softDelete: function(model, is_deleted) -- change value of is_deleted
198 *
199 * @param ModelClass
200 */
201 CRM.Backbone.trackSoftDelete = function(ModelClass) {
202 // Retain references to some of the original class's functions
203 var Parent = _.pick(ModelClass.prototype, 'save');
204
205 // Defaults - if specified in ModelClass, preserve
206 _.defaults(ModelClass.prototype, {
207 is_soft_deleted: false,
208 setSoftDeleted: function(is_deleted) {
209 if (this.is_soft_deleted != is_deleted) {
210 this.is_soft_deleted = is_deleted;
211 this.trigger('softDelete', this, is_deleted);
212 if (this.setModified) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
213 }
214 },
215 isSoftDeleted: function() {
216 return this.is_soft_deleted;
217 }
218 });
219
220 // Overrides - if specified in ModelClass, replace
221 _.extend(ModelClass.prototype, {
222 save: function(attributes, options) {
223 if (this.isSoftDeleted()) {
224 return this.destroy(options);
225 } else {
226 return Parent.save.apply(this, arguments);
227 }
228 }
229 });
230 };
231
232 /**
233 * Connect a "collection" class to CiviCRM's APIv3
234 *
235 * Note: the collection supports a special property, crmCriteria, which is an array of
236 * query options to send to the API
237 *
238 * @code
239 * // Setup class
240 * var ContactModel = Backbone.Model.extend({});
241 * CRM.Backbone.extendModel(ContactModel, "Contact");
242 * var ContactCollection = Backbone.Collection.extend({
243 * model: ContactModel
244 * });
245 * CRM.Backbone.extendCollection(ContactCollection);
246 *
247 * // Use class
248 * var c = new ContactCollection([], {
249 * crmCriteria: {contact_type: 'Organization'}
250 * });
251 * c.fetch();
252 * @endcode
253 *
254 * @param Class CollectionClass
255 * @see tests/qunit/crm-backbone
256 */
257 CRM.Backbone.extendCollection = function(CollectionClass) {
258 var origInit = CollectionClass.prototype.initialize;
259 // Defaults - if specified in CollectionClass, preserve
260 _.defaults(CollectionClass.prototype, {
261 crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
262 toCrmCriteria: function() {
263 return this.crmCriteria || {};
264 }
265 });
266 // Overrides - if specified in CollectionClass, replace
267 _.extend(CollectionClass.prototype, {
268 sync: CRM.Backbone.sync,
269 initialize: function(models, options) {
270 options || (options = {});
271 if (options.crmCriteria) {
272 this.crmCriteria = options.crmCriteria;
273 }
274 if (origInit) {
275 return origInit.apply(this, arguments);
276 }
277 }
278 });
279 };
280
281 /**
282 * Find a single record, or create a new record.
283 *
284 * @param Object options:
285 * - CollectionClass: class
286 * - crmCriteria: Object values to search/default on
287 * - defaults: Object values to put on newly created model (if needed)
288 * - success: function(model)
289 * - error: function(collection, error)
290 */
291 CRM.Backbone.findCreate = function(options) {
292 options || (options = {});
293 var collection = new options.CollectionClass([], {
294 crmCriteria: options.crmCriteria
295 });
296 collection.fetch({
297 success: function(collection) {
298 if (collection.length == 0) {
299 var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
300 var model = collection._prepareModel(attrs, options);
301 options.success(model);
302 } else if (collection.length == 1) {
303 options.success(collection.first());
304 } else {
305 options.error(collection, {
306 is_error: 1,
307 error_message: 'Too many matches'
308 });
309 }
310 },
311 error: function(collection, errorData) {
312 if (options.error) {
313 options.error(collection, errorData);
314 }
315 }
316 });
317 };
318
319
320 CRM.Backbone.Model = Backbone.Model.extend({
321 /**
322 * Return JSON version of model -- but only include fields that are
323 * listed in the 'schema'.
324 *
325 * @return {*}
326 */
327 toStrictJSON: function() {
328 var schema = this.schema;
329 var result = this.toJSON();
330 _.each(result, function(value, key) {
331 if (!schema[key]) {
332 delete result[key];
333 }
334 });
335 return result;
336 },
337 setRel: function(key, value, options) {
338 this.rels = this.rels || {};
339 if (this.rels[key] != value) {
340 this.rels[key] = value;
341 this.trigger("rel:" + key, value);
342 }
343 },
344 getRel: function(key) {
345 return this.rels ? this.rels[key] : null;
346 }
347 });
348
349 CRM.Backbone.Collection = Backbone.Collection.extend({
350 /**
351 * Store 'key' on this.rel and automatically copy it to
352 * any children.
353 *
354 * @param key
355 * @param value
356 * @param initialModels
357 */
358 initializeCopyToChildrenRelation: function(key, value, initialModels) {
359 this.setRel(key, value, {silent: true});
360 this.on('reset', this._copyToChildren, this);
361 this.on('add', this._copyToChild, this);
362 },
363 _copyToChildren: function() {
364 var collection = this;
365 collection.each(function(model) {
366 collection._copyToChild(model);
367 });
368 },
369 _copyToChild: function(model) {
370 _.each(this.rels, function(relValue, relKey) {
371 model.setRel(relKey, relValue, {silent: true});
372 });
373 },
374 setRel: function(key, value, options) {
375 this.rels = this.rels || {};
376 if (this.rels[key] != value) {
377 this.rels[key] = value;
378 this.trigger("rel:" + key, value);
379 }
380 },
381 getRel: function(key) {
382 return this.rels ? this.rels[key] : null;
383 }
384 });
385
386 /*
387 CRM.Backbone.Form = Backbone.Form.extend({
388 validate: function() {
389 // Add support for form-level validators
390 var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
391 var self = this;
392 if (this.validators) {
393 _.each(this.validators, function(validator) {
394 var modelErrors = validator(this.getValue());
395
396 // The following if() has been copied-pasted from the parent's
397 // handling of model-validators. They are similar in that the errors are
398 // probably keyed by field names... but not necessarily, so we use _others
399 // as a fallback.
400 if (modelErrors) {
401 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
402
403 //If errors are not in object form then just store on the error object
404 if (!isDictionary) {
405 errors._others = errors._others || [];
406 errors._others.push(modelErrors);
407 }
408
409 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
410 if (isDictionary) {
411 _.each(modelErrors, function(val, key) {
412 //Set error on field if there isn't one already
413 if (self.fields[key] && !errors[key]) {
414 self.fields[key].setError(val);
415 errors[key] = val;
416 }
417
418 else {
419 //Otherwise add to '_others' key
420 errors._others = errors._others || [];
421 var tmpErr = {};
422 tmpErr[key] = val;
423 errors._others.push(tmpErr);
424 }
425 });
426 }
427 }
428
429 });
430 }
431 return _.isEmpty(errors) ? null : errors;
432 }
433 });
434 */
435 })(cj);