X-Git-Url: https://vcs.fsf.org/?a=blobdiff_plain;f=js%2Fcrm.backbone.js;h=746ddaf7be3559ee8aa0d9529885de9a8b85d71a;hb=44b74e45ade914fcf456232f5d69886cc4252a1f;hp=f12d671006f0ab4ca7daea1b34e701fe0f3199ab;hpb=3c97257f523cbbc0549c93374e67edba3ed96f05;p=civicrm-core.git diff --git a/js/crm.backbone.js b/js/crm.backbone.js index f12d671006..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,42 +296,30 @@ _.defaults(CollectionClass.prototype, { crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName, toCrmCriteria: function() { - return this.crmCriteria || {}; + return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {}; }, /** - * Find a single match, or create a (new) matching record. If a new record is created, - * it will be added to the collection but NOT saved. + * 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: - * - success: function(model) - * - error: function(collection, error) - * - defaults: Object values to put on newly created model (if needed) + * @param Object options - accepts "success" and "error" callbacks */ - fetchCreate: function(options) { + save: function(options) { options || (options = {}); - this.fetch({ - success: function(collection) { - if (collection.length == 0) { - var attrs = _.extend({}, collection.crmCriteria, options.defaults || {}); - var model = collection._prepareModel(attrs, options); - collection.add(model, options); - options.success(model); - } else if (collection.length == 1) { - options.success(collection.first()); - } else { - options.error(collection, { - is_error: 1, - error_message: 'Too many matches' - }); - } - }, - error: function(collection, errorData) { - if (options.error) { - options.error(collection, errorData); - } - } - }); + 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 @@ -192,10 +333,61 @@ 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; + } + }); + }; + + /** + * Find a single record, or create a new record. + * + * @param Object options: + * - CollectionClass: class + * - crmCriteria: Object values to search/default on + * - defaults: Object values to put on newly created model (if needed) + * - success: function(model) + * - error: function(collection, error) + */ + CRM.Backbone.findCreate = function(options) { + options || (options = {}); + var collection = new options.CollectionClass([], { + crmCriteria: options.crmCriteria + }); + collection.fetch({ + success: function(collection) { + if (collection.length == 0) { + var attrs = _.extend({}, collection.crmCriteria, options.defaults || {}); + var model = collection._prepareModel(attrs, options); + options.success(model); + } else if (collection.length == 1) { + options.success(collection.first()); + } else { + options.error(collection, { + is_error: 1, + error_message: 'Too many matches' + }); + } + }, + error: function(collection, errorData) { + if (options.error) { + options.error(collection, errorData); + } } }); }; + CRM.Backbone.Model = Backbone.Model.extend({ /** * Return JSON version of model -- but only include fields that are @@ -311,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);