CRM-12923, CRM-12943 - Track whether model changes have been saved to the server
[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 var oldModified = this._modified;
156 this._modified = true;
157 if (!oldModified) {
158 this.trigger('saved', this, this.isSaved());
159 }
160 }
161 });
162
163 // Overrides - if specified in ModelClass, replace
164 _.extend(ModelClass.prototype, {
165 initialize: function(options) {
166 this._modified = false;
167 this._oldModified = [];
168 this.listenTo(this, 'change', this._saved_onchange);
169 this.listenTo(this, 'error', onSaveError);
170 this.listenTo(this, 'sync', onSyncSuccess);
171 if (Parent.initialize) {
172 return Parent.initialize.apply(this, arguments);
173 }
174 },
175 save: function() {
176 // we'll assume success
177 this._oldModified.push(this._modified);
178 return Parent.save.apply(this, arguments);
179 },
180 fetch: function() {
181 this._oldModified.push(this._modified);
182 return Parent.fetch.apply(this, arguments);
183 }
184 });
185 };
186
187 /**
188 * Connect a "collection" class to CiviCRM's APIv3
189 *
190 * Note: the collection supports a special property, crmCriteria, which is an array of
191 * query options to send to the API
192 *
193 * @code
194 * // Setup class
195 * var ContactModel = Backbone.Model.extend({});
196 * CRM.Backbone.extendModel(ContactModel, "Contact");
197 * var ContactCollection = Backbone.Collection.extend({
198 * model: ContactModel
199 * });
200 * CRM.Backbone.extendCollection(ContactCollection);
201 *
202 * // Use class
203 * var c = new ContactCollection([], {
204 * crmCriteria: {contact_type: 'Organization'}
205 * });
206 * c.fetch();
207 * @endcode
208 *
209 * @param Class CollectionClass
210 * @see tests/qunit/crm-backbone
211 */
212 CRM.Backbone.extendCollection = function(CollectionClass) {
213 var origInit = CollectionClass.prototype.initialize;
214 // Defaults - if specified in CollectionClass, preserve
215 _.defaults(CollectionClass.prototype, {
216 crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
217 toCrmCriteria: function() {
218 return this.crmCriteria || {};
219 }
220 });
221 // Overrides - if specified in CollectionClass, replace
222 _.extend(CollectionClass.prototype, {
223 sync: CRM.Backbone.sync,
224 initialize: function(models, options) {
225 options || (options = {});
226 if (options.crmCriteria) {
227 this.crmCriteria = options.crmCriteria;
228 }
229 if (origInit) {
230 return origInit.apply(this, arguments);
231 }
232 }
233 });
234 };
235
236 /**
237 * Find a single record, or create a new record.
238 *
239 * @param Object options:
240 * - CollectionClass: class
241 * - crmCriteria: Object values to search/default on
242 * - defaults: Object values to put on newly created model (if needed)
243 * - success: function(model)
244 * - error: function(collection, error)
245 */
246 CRM.Backbone.findCreate = function(options) {
247 options || (options = {});
248 var collection = new options.CollectionClass([], {
249 crmCriteria: options.crmCriteria
250 });
251 collection.fetch({
252 success: function(collection) {
253 if (collection.length == 0) {
254 var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
255 var model = collection._prepareModel(attrs, options);
256 options.success(model);
257 } else if (collection.length == 1) {
258 options.success(collection.first());
259 } else {
260 options.error(collection, {
261 is_error: 1,
262 error_message: 'Too many matches'
263 });
264 }
265 },
266 error: function(collection, errorData) {
267 if (options.error) {
268 options.error(collection, errorData);
269 }
270 }
271 });
272 };
273
274
275 CRM.Backbone.Model = Backbone.Model.extend({
276 /**
277 * Return JSON version of model -- but only include fields that are
278 * listed in the 'schema'.
279 *
280 * @return {*}
281 */
282 toStrictJSON: function() {
283 var schema = this.schema;
284 var result = this.toJSON();
285 _.each(result, function(value, key) {
286 if (!schema[key]) {
287 delete result[key];
288 }
289 });
290 return result;
291 },
292 setRel: function(key, value, options) {
293 this.rels = this.rels || {};
294 if (this.rels[key] != value) {
295 this.rels[key] = value;
296 this.trigger("rel:" + key, value);
297 }
298 },
299 getRel: function(key) {
300 return this.rels ? this.rels[key] : null;
301 }
302 });
303
304 CRM.Backbone.Collection = Backbone.Collection.extend({
305 /**
306 * Store 'key' on this.rel and automatically copy it to
307 * any children.
308 *
309 * @param key
310 * @param value
311 * @param initialModels
312 */
313 initializeCopyToChildrenRelation: function(key, value, initialModels) {
314 this.setRel(key, value, {silent: true});
315 this.on('reset', this._copyToChildren, this);
316 this.on('add', this._copyToChild, this);
317 },
318 _copyToChildren: function() {
319 var collection = this;
320 collection.each(function(model) {
321 collection._copyToChild(model);
322 });
323 },
324 _copyToChild: function(model) {
325 _.each(this.rels, function(relValue, relKey) {
326 model.setRel(relKey, relValue, {silent: true});
327 });
328 },
329 setRel: function(key, value, options) {
330 this.rels = this.rels || {};
331 if (this.rels[key] != value) {
332 this.rels[key] = value;
333 this.trigger("rel:" + key, value);
334 }
335 },
336 getRel: function(key) {
337 return this.rels ? this.rels[key] : null;
338 }
339 });
340
341 /*
342 CRM.Backbone.Form = Backbone.Form.extend({
343 validate: function() {
344 // Add support for form-level validators
345 var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
346 var self = this;
347 if (this.validators) {
348 _.each(this.validators, function(validator) {
349 var modelErrors = validator(this.getValue());
350
351 // The following if() has been copied-pasted from the parent's
352 // handling of model-validators. They are similar in that the errors are
353 // probably keyed by field names... but not necessarily, so we use _others
354 // as a fallback.
355 if (modelErrors) {
356 var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
357
358 //If errors are not in object form then just store on the error object
359 if (!isDictionary) {
360 errors._others = errors._others || [];
361 errors._others.push(modelErrors);
362 }
363
364 //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
365 if (isDictionary) {
366 _.each(modelErrors, function(val, key) {
367 //Set error on field if there isn't one already
368 if (self.fields[key] && !errors[key]) {
369 self.fields[key].setError(val);
370 errors[key] = val;
371 }
372
373 else {
374 //Otherwise add to '_others' key
375 errors._others = errors._others || [];
376 var tmpErr = {};
377 tmpErr[key] = val;
378 errors._others.push(tmpErr);
379 }
380 });
381 }
382 }
383
384 });
385 }
386 return _.isEmpty(errors) ? null : errors;
387 }
388 });
389 */
390 })(cj);