X-Git-Url: https://vcs.fsf.org/?a=blobdiff_plain;f=js%2Fcrm.backbone.js;h=746ddaf7be3559ee8aa0d9529885de9a8b85d71a;hb=6994c77d3f14c20bc0454cd83897271d0574c237;hp=2e2e381fe82804720dde8c7b939c8673fb91edaf;hpb=92ccac7f9750778612afde1bbb3449963f0ff171;p=civicrm-core.git diff --git a/js/crm.backbone.js b/js/crm.backbone.js index 2e2e381fe8..746ddaf7be 100644 --- a/js/crm.backbone.js +++ b/js/crm.backbone.js @@ -8,7 +8,7 @@ * To load collections using API queries, set the "crmCriteria" property or override the * method "toCrmCriteria". * - * @param method + * @param method Accepts normal Backbone.sync methods; also accepts "crm-replace" * @param model * @param options * @see tests/qunit/crm-backbone @@ -34,6 +34,13 @@ case 'read': CRM.api(model.crmEntityName, 'get', model.toCrmCriteria(), apiOptions); break; + // replace all entities matching "x.crmCriteria" with new entities in "x.models" + case 'crm-replace': + var params = this.toCrmCriteria(); + params.version = 3; + params.values = this.toJSON(); + CRM.api(model.crmEntityName, 'replace', params, apiOptions); + break; default: apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"}); break; @@ -63,7 +70,14 @@ switch (method) { case 'create': // pass-through case 'update': - CRM.api(model.crmEntityName, 'create', model.toJSON(), apiOptions); + var params = model.toJSON(); + params.options || (params.options = {}); + params.options.reload = 1; + if (!model._isDuplicate) { + CRM.api(model.crmEntityName, 'create', params, apiOptions); + } else { + CRM.api(model.crmEntityName, 'duplicate', params, apiOptions); + } break; case 'read': case 'delete': @@ -104,6 +118,16 @@ crmEntityName: crmEntityName, toCrmCriteria: function() { return (this.get('id')) ? {id: this.get('id')} : {}; + }, + duplicate: function() { + var newModel = new ModelClass(this.toJSON()); + newModel._isDuplicate = true; + if (newModel.setModified) newModel.setModified(); + newModel.listenTo(newModel, 'sync', function(){ + // may get called on subsequent resaves -- don't care! + delete newModel._isDuplicate; + }); + return newModel; } }); // Overrides - if specified in ModelClass, replace @@ -113,10 +137,136 @@ }; /** + * Configure a model class to track whether a model has unsaved changes. + * + * Methods: + * - setModified() - flag the model as modified/dirty + * - isSaved() - return true if there have been no changes to the data since the last fetch or save + * Events: + * - saved(object model, bool is_saved) - triggered whenever isSaved() value would change + * + * Note: You should not directly call isSaved() within the context of the success/error/sync callback; + * I haven't found a way to make isSaved() behave correctly within these callbacks without patching + * Backbone. Instead, attach an event listener to the 'saved' event. + * + * @param ModelClass + */ + CRM.Backbone.trackSaved = function(ModelClass) { + // Retain references to some of the original class's functions + var Parent = _.pick(ModelClass.prototype, 'initialize', 'save', 'fetch'); + + // Private callback + var onSyncSuccess = function() { + this._modified = false; + if (this._oldModified.length > 0) { + this._oldModified.pop(); + } + this.trigger('saved', this, this.isSaved()); + }; + var onSaveError = function() { + if (this._oldModified.length > 0) { + this._modified = this._oldModified.pop(); + this.trigger('saved', this, this.isSaved()); + } + }; + + // Defaults - if specified in ModelClass, preserve + _.defaults(ModelClass.prototype, { + isSaved: function() { + var result = !this.isNew() && !this.isModified(); + return result; + }, + isModified: function() { + return this._modified; + }, + _saved_onchange: function(model, options) { + if (options.parse) return; + // console.log('change', model.changedAttributes(), model.previousAttributes()); + this.setModified(); + }, + setModified: function() { + var oldModified = this._modified; + this._modified = true; + if (!oldModified) { + this.trigger('saved', this, this.isSaved()); + } + } + }); + + // Overrides - if specified in ModelClass, replace + _.extend(ModelClass.prototype, { + initialize: function(options) { + this._modified = false; + this._oldModified = []; + this.listenTo(this, 'change', this._saved_onchange); + this.listenTo(this, 'error', onSaveError); + this.listenTo(this, 'sync', onSyncSuccess); + if (Parent.initialize) { + return Parent.initialize.apply(this, arguments); + } + }, + save: function() { + // we'll assume success + this._oldModified.push(this._modified); + return Parent.save.apply(this, arguments); + }, + fetch: function() { + this._oldModified.push(this._modified); + return Parent.fetch.apply(this, arguments); + } + }); + }; + + /** + * Configure a model class to support client-side soft deletion. + * One can call "model.setDeleted(BOOLEAN)" to flag an entity for + * deletion (or not) -- however, deletion will be deferred until save() + * is called. + * + * Methods: + * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted) + * isSoftDeleted() - determine whether model has been soft-deleted + * Events: + * softDelete(model, is_deleted) -- change value of is_deleted + * + * @param ModelClass + */ + CRM.Backbone.trackSoftDelete = function(ModelClass) { + // Retain references to some of the original class's functions + var Parent = _.pick(ModelClass.prototype, 'save'); + + // Defaults - if specified in ModelClass, preserve + _.defaults(ModelClass.prototype, { + is_soft_deleted: false, + setSoftDeleted: function(is_deleted) { + if (this.is_soft_deleted != is_deleted) { + this.is_soft_deleted = is_deleted; + this.trigger('softDelete', this, is_deleted); + if (this.setModified) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved + } + }, + isSoftDeleted: function() { + return this.is_soft_deleted; + } + }); + + // Overrides - if specified in ModelClass, replace + _.extend(ModelClass.prototype, { + save: function(attributes, options) { + if (this.isSoftDeleted()) { + return this.destroy(options); + } else { + return Parent.save.apply(this, arguments); + } + } + }); + }; + + /** * Connect a "collection" class to CiviCRM's APIv3 * * Note: the collection supports a special property, crmCriteria, which is an array of - * query options to send to the API + * query options to send to the API. * * @code * // Setup class @@ -132,6 +282,9 @@ * crmCriteria: {contact_type: 'Organization'} * }); * c.fetch(); + * c.get(123).set('property', 'value'); + * c.get(456).setDeleted(true); + * c.save(); * @endcode * * @param Class CollectionClass @@ -143,7 +296,30 @@ _.defaults(CollectionClass.prototype, { crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName, toCrmCriteria: function() { - return this.crmCriteria || {}; + return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {}; + }, + + /** + * Reconcile the server's collection with the client's collection. + * New/modified items from the client will be saved/updated on the + * server. Deleted items from the client will be deleted on the + * server. + * + * @param Object options - accepts "success" and "error" callbacks + */ + save: function(options) { + options || (options = {}); + var collection = this; + var success = options.success; + options.success = function(resp) { + // Ensure attributes are restored during synchronous saves. + collection.reset(resp, options); + if (success) success(collection, resp, options); + // collection.trigger('sync', collection, resp, options); + }; + wrapError(collection, options); + + return this.sync('crm-replace', this, options) } }); // Overrides - if specified in CollectionClass, replace @@ -157,6 +333,18 @@ if (origInit) { return origInit.apply(this, arguments); } + }, + toJSON: function() { + var result = []; + // filter models list, excluding any soft-deleted items + this.each(function(model) { + // if model doesn't track soft-deletes + // or if model tracks soft-deletes and wasn't soft-deleted + if (!model.isSoftDeleted || !model.isSoftDeleted()) { + result.push(model.toJSON()); + } + }); + return result; } }); }; @@ -315,4 +503,13 @@ } }); */ + + // Wrap an optional error callback with a fallback error event. + var wrapError = function (model, options) { + var error = options.error; + options.error = function(resp) { + if (error) error(model, resp, options); + model.trigger('error', model, resp, options); + }; + }; })(cj);