• Jump To … +
    modules/Behavior.js modules/Cell.js modules/Collection.js modules/Events.js modules/FormModel.js modules/FormView.js modules/ListView.js modules/Model.js modules/NestedCell.js modules/NestedModel.js modules/Router.js modules/ServiceCell.js modules/View.js modules/behaviors/DataBehavior.js modules/configure.js modules/handlebarsUtils.js modules/history.js modules/mixins/cacheMixin.js modules/mixins/cellMixin.js modules/mixins/loadingMixin.js modules/mixins/modelMixin.js modules/mixins/pollingMixin.js modules/registry.js modules/stickitUtils.js modules/templateRenderer.js modules/torso.js modules/validation.js
  • FormModel.js

  • ¶
    (function(root, factory) {
      if (typeof define === 'function' && define.amd) {
        define(['underscore', 'backbone', './NestedModel', './validation'], factory);
      } else if (typeof exports === 'object') {
        module.exports = factory(require('underscore'), require('backbone'), require('./NestedModel'), require('./validation'));
      } else {
        root.Torso = root.Torso || {};
        root.Torso.FormModel = factory(root._, root.Backbone, root.Torso.NestedModel, root.Torso.validation);
      }
    }(this, function(_, Backbone, NestedModel, validation) {
      'use strict';
    
      var $ = Backbone.$;
    
      /**
       * Generic Form Model
       *
       * @class FormModel
       * @extends NestedModel
       * @mixes validationMixin
       *
       * @author kent.willis@vecna.com
       *
       * @see <a href="../annotated/modules/FormModel.html">FormModel Annotated Source</a>
       */
      var FormModel = NestedModel.extend(/** @lends FormModel# */{
        /**
         * @private
         * @property __currentMappings
         * @type Object
         */
        /**
         * @private
         * @property __cache
         * @type Object
         */
        /**
         * @private
         * @property __currentObjectModels
         * @type Object
         */
        /**
         * @private
         * @property __currentUpdateEvents
         * @type Array
         */
        /**
         * @property validation
         * @type Object
         */
        /**
         * @property labels
         * @type Object
         */
        /**
         * Map from aliases (either model names or computed value names) to mappings.
         * Please refer to the documentation on the constructor about the form and options for this field.
         * @property mapping
         * @type Object
         */
        mapping: undefined,
    
        /**
         * Map from model aliases to model instances.
         * Please refer to the documentation on the constructor about the form and options for this field.
         * @property models
         * @type Object
         */
        models: undefined,
    
        /**
         * Constructor the form model. Can take in attributes to set initially. These will override any pulled values from object models
         * on initialization. On initialization the object model's values will be pulled once.
         * For the options, here are needed definitions:
         * mapping: {
         *   modelName: 'foo bar baz' // track a model by providing an alias for a name and a space seperated list of fields to track as a String
         *   modelName2: true          // to track all fields
         *   ...                      // can have many model mappings
         *   computedName: {
         *     modelName: 'taz raz',  // mappings for models that will be used for this computed mapping.
         *     ...                    // can have many model mappings for a computed
         *     pull: function(models) {}, // a callback that will be invoked when pulling data from the Object model. Passes in a map of model alias/name to shallow copies of fields being tracked on that model.
         *     push: function(models) {}  // a callback that will be invoked when pushing data to the Object model. Passes in a map of model alias/name to object model being tracked under that alias.
         *   }
         * },
         * models: {
         *   modelName: modelInstance,  // optionally, provide a set of model instance to model name (aliases) to start tracking
         *   modelName2: modelInstance2 // provide as many aliases to model instances as you'd like
         * }
         * @param {Object} [options]
         *   @param {Object} [options.mapping] map from aliases (either model names or computed value names) to mappings.
         *     A model mapping can bind an alias to a space seperated list of fields to track as a String  r the boolean true if it is mapping all the
         *     fields. A computed mapping can bind an alias to a set of model mappings required for this computed value and both a pull and/or push method
         *     that are used to compute different values to or from object model(s).
         *   @param {Object} [options.models] Because the options.mapping parameter only allows you to define the mappings to aliases, this options allows
         *     you to bind model instances to aliases. Setting model instances to aliases are required to actually begin pulling/pushing values.
         *   @param {boolean} [options.startUpdating=false] set to true if you want to immediately set up listeners to update this form
         *     model as the object model updates. You can always toggle this state with startUpdating() and stopUpdating().
         *   @param {Object} [options.validation] A Backbone.Validation plugin hash to dictate the validation rules
         *   @param {Object} [options.labels] A Backbone.Validation plugin hash to dictate the attribute labels
         */
        constructor: function(attributes, options) {
          options = options || {};
          this.__cache = {};
          this.__currentUpdateEvents = [];
          this.__currentMappings = {};
          this.__currentObjectModels = {};
  • ¶

    override + extend the validation and labels hashes

          this.validation = _.extend({}, this.validation, options.validation);
          this.labels = _.extend({}, this.labels, options.labels);
    
          NestedModel.apply(this, arguments);
    
          this.__initMappings(options);
  • ¶

    Do an initial pull

          this.pull();
  • ¶

    The pull may have overridden default attributes

          if (attributes) {
            this.set(attributes);
          }
  • ¶

    Begin updating if requested

          if (options.startUpdating) {
            this.startUpdating();
          }
          this.trigger('initialization-complete');
        },
    
        /**
         * @param {string} alias the alias of the mapping - either a model mapping or a computed mapping
         * @returns the mapping config for that alias
         */
        getMapping: function(alias) {
          return this.__currentMappings[alias];
        },
    
        /**
         * @returns all the current mapping configs
         */
        getMappings: function() {
          return this.__currentMappings;
        },
    
        /**
         * Define or redefine how the form model pull/pushes or otherwise tracks properties between an object model(s).
         * Examples:
         * this.setMapping('modelAlias', true, optional model instance);
         * this.setMapping('modelAlias, 'foo bar baz', optional model instance);
         * this.setMapping('computedAlias', {
         *   model1: 'foo',
         *   model2: 'bar',
         *   push: function(models) {
         *     models.model1.set('foo', this.get('foobar')[0]);
         *     models.model2.set('bar', this.get('foobar')[1]);
         *   },
         *   pull: function(models) {
         *     this.set('foobar', [models.model1.foo, models.model2.bar]);
         *   },
         * }, optional model map)
         * @param {string} alias the name for the mapping - either a model mapping or a computed mapping
         * @param {(string|boolean|Object)} mapping Provides the mapping for this alias. If trying to map to a model, then either provide
         *  a space delimited list of fields to track as a String or the boolean true to track all the model's fields. If the mapping is for
         *  a computed value, then provide a map from model alias to model mapping for all the fields needed for the computed and a pull method
         *  if you want to change/combine/split object model properties before bringing them into the form model and a push method if you want to
         *  change/combine/split form model properties before pushing them to the object models.
         * @param {Object|external:Backbone-Model} [models] Provides instances to use for this mapping. If mapping is a computed,
         *   provide a map from alias to model instance. If mapping is for a single model, just provide the model instance for that alias.
         * @param [copy=false] if true, will pull values definined by this mapping after setting the mapping. Requires models to be passed in.
         */
        setMapping: function(alias, mapping, models, copy) {
          var computed, fields,
            config = {};
          if (_.isString(mapping)) {
            fields = mapping.split(' ');
          } else if (mapping === true) {
            fields = undefined;
          } else if (_.isObject(mapping)) {
            mapping = _.clone(mapping);
            computed = true;
          }
          config.computed = computed;
          if (computed) {
            config.mapping = mapping;
            _.each(this.__getModelAliases(config), function(modelAlias) {
              var configMappingForAlias = config.mapping[modelAlias];
              if (_.isString(configMappingForAlias)) {
                configMappingForAlias = configMappingForAlias.split(' ');
              } else if (configMappingForAlias === true) {
                configMappingForAlias = undefined;
              }
              config.mapping[modelAlias] = configMappingForAlias;
            });
          } else {
            config.mapping = fields;
          }
          this.__currentMappings[alias] = config;
          if (models) {
            if (computed) {
              this.trackModels(models, copy);
            } else {
              this.trackModel(alias, models, copy);
            }
          }
        },
    
        /**
         * Sets multiple mappings (both model mappings and computed value mappings) with one call.
         * Uses the same style of mapping syntax as the constructor. Please refer to the documentation on the constructor.
         * Here is an example:
         * this.setMappings({
         *   model1: 'foo bar',
         *   model2: 'baz',
         *   ssn: {
         *     model1: 'ssn',
         *     model2: 'lastssn'
         *     push: function(models) {},
         *     pull: function(models) {},
         *   }
         * }, optional model map)
         * @param {Object} mappings Uses the same style of mapping syntax as the constructor. Please refer to the documentation on the constructor.
         * @param {Object} [models] this parameter allows you to immediately bind model instances to aliases. Keys are aliases and values are external:Backbone-Models.
         * @param [copy=false] if true, will pull values definined by this mapping after setting the mapping. Requires models to be passed in.
         */
        setMappings: function(mappings, models, copy) {
          _.each(mappings, function(mapping, alias) {
            this.setMapping(alias, mapping);
          }, this);
          if (models) {
            this.trackModels(models, copy);
          }
        },
    
        /**
         * Remove a mapping (model or computed) by alias
         * @param {string|external:Backbone-Model} aliasOrModel if a String is provided, it will unset the mapping with that alias.
         *   If a external:Backbone-Model is provided, it will remove the model mapping that was bound to that model.
         * @param {boolean} [removeModelIfUntracked=false] If true, after the mapping is removed, the model will also be unset but only if
         *   no other mappings reference it. Note, setting this to true will not remove any computed mappings that also use that model.
         */
        unsetMapping: function(aliasOrModel, removeModelIfUntracked) {
          var alias = this.__findAlias(aliasOrModel);
          if (alias) {
            delete this.__currentMappings[alias];
          }
          var model = this.getTrackedModel(alias);
          if (removeModelIfUntracked && model && _.isEmpty(this.__getTrackedModelFields(model))) {
            this.untrackModel(model);
          }
        },
    
        /**
         * Removes all current mappings
         * Does NOT remove current model being tracked. Call this.untrackModels afterwards if you wish this behavior.
         */
        unsetMappings: function() {
          this.__currentMappings = {};
          this.resetUpdating();
        },
    
        /**
         * Returns the object model currently bound to the given name/alias.
         * @param {string} alias the name/alias used by the mappings.
         * @returns {external:Backbone-Model} the model currently bound to the alias
         */
        getTrackedModel: function(alias) {
          return this.__currentObjectModels[alias];
        },
    
        /**
         * Returns all the currently tracked object models
         * @returns all the currently tracked object models
         */
        getTrackedModels: function() {
          return _.values(this.__currentObjectModels);
        },
    
        /**
         * Use {@link FormModel#trackModel} instead.
         * @see {@link FormModel#trackModel}
         * @deprecated
         */
        setTrackedModel: function() {
          this.trackModel.apply(this, arguments);
        },
    
        /**
         * Update or create a binding between an object model and an alias.
         * @param {string} alias the alias/name to bind to.
         * @param {external:Backbone-Model} model the model to be bound. Mappings referencing this alias will start applying to this model.
         * @param {boolean} [copy=false] if true, the form model will perform a pull on any mappings using this alias.
         */
        trackModel: function(alias, model, copy) {
          this.__currentObjectModels[alias] = model;
          this.__updateCache(model);
          this.resetUpdating();
          if (copy) {
            _.each(this.getMappings(), function(config, mappingAlias) {
              var modelAliases;
              if (alias === mappingAlias) {
                this.__pull(mappingAlias);
              }
              if (config.computed) {
                modelAliases = this.__getModelAliases(mappingAlias);
                if (_.contains(modelAliases, alias)) {
                  this.__pull(mappingAlias);
                }
              }
            }, this);
          }
        },
    
        /**
         * Use {@link FormModel#trackModels} instead.
         * @see {@link FormModel#trackModels}
         * @deprecated
         */
        setTrackedModels: function() {
          this.trackModels.apply(this, arguments);
        },
    
        /**
         * Binds multiple models to their aliases.
         * @param {Object.<string, external:Backbone-Model>} models A map from alias/name to model to be bound to that alias.
         * @param {boolean} [copy=false] if true, the form model will perform a pull on any mapping using these models.
         */
        trackModels: function(models, copy) {
          _.each(models, function(instance, alias) {
            this.trackModel(alias, instance, copy);
          }, this);
        },
    
        /**
         * Use {@link FormModel#untrackModel} instead.
         * @see {@link FormModel#untrackModel}
         * @deprecated
         */
        unsetTrackedModel: function() {
          this.untrackModel.apply(this, arguments);
        },
    
        /**
         * Removes the binding between a model alias and a model instance. Effectively stops tracking that model.
         * @param {string|external:Backbone-Model} aliasOrModel If a string is given, it will unset the model using that alias. If a model instance
         *   is given, it will unbind whatever alias is currently bound to it.
         */
        untrackModel: function(aliasOrModel) {
          var model,
            alias = this.__findAlias(aliasOrModel);
          if (alias) {
            model = this.__currentObjectModels[alias];
            delete this.__currentObjectModels[alias];
            this.__updateCache(model);
          }
          this.resetUpdating();
        },
    
        /**
         * Use {@link FormModel#untrackModels} instead.
         * @see {@link FormModel#untrackModels}
         * @deprecated
         */
        unsetTrackedModels: function() {
          this.untrackModels.apply(this, arguments);
        },
    
        /**
         * Removes all the bindings between model aliases and model instances. Effectively stops tracking the current models.
         */
        untrackModels: function() {
          this.__currentObjectModels = [];
          this.__updateCache();
          this.resetUpdating();
        },
    
        /**
         * Pushes values from this form model back to the object models it is tracking. This includes invoking the push callbacks from
         * computed values
         */
        push: function() {
          _.each(this.getMappings(), function(config, alias) {
            this.__push(alias);
          }, this);
        },
    
        /**
         * Pulls the most recent values of every object model that this form model tracks including computed values
         * NOTE: using this method can override user-submitted data from an HTML form. Use caution.
         */
        pull: function() {
          _.each(this.getMappings(), function(config, alias) {
            this.__pull(alias);
          }, this);
          this.__updateCache();
        },
    
        /**
         * If FormModel has a "url" property defined, it will invoke a save on the form model, and after successfully
         * saving, will perform a push.
         * If no "url" property is defined then the following behavior is used:
         * Pushes the form model values to the object models it is tracking and invokes save on each one. Returns a promise.
         * NOTE: if no url is specified and no models are being tracked, it will instead trigger a 'save-fail' event and reject the returned promise
         * with a payload that mimics a server response: {none: { success: false, response: [{ responseJSON: { generalReasons: [{messageKey: 'no.models.were.bound.to.form'}] }}] }}
         * @param {Object} [options]
         *   @param {boolean} [options.rollback=true] if true, when any object model fails to save, it will revert the object
         *     model attributes to the state they were before calling save. NOTE: if there are updates that happen
         *     to object models within the timing of this save method, the updates could be lost.
         *   @param {boolean} [options.force=true] if false, the form model will check to see if an update has been made
         *     to any object models it is tracking since it's last pull. If any stale data is found, save with throw an exception
         *     with attributes: {name: 'Stale data', staleModels: [Array of model cid's]}
         * @returns when using a "url", a promise is returned for the save on this form model.
             If not using a "url", a promise that will either resolve when all the models have successfully saved in which case the context returned
         *   is an array of the responses (order determined by first the array of models and then the array of models used by
         *   the computed values, normalized), or if any of the saves fail, the promise will be rejected with an array of responses.
         *   Note: the size of the failure array will always be one - the first model that failed. This is a side-effect of $.when
         */
        save: function(options) {
          var notTrackingResponse, url,
            deferred = new $.Deferred(),
            formModel = this;
          options = options || {};
          _.defaults(options, {
            rollback: true,
            force: true
          });
          try {
            url = _.result(formModel, 'url');
          } catch (e) {
  • ¶

    no url attached to this form model. Continue by pushing to models.

          }
          if (url) {
            return NestedModel.prototype.save.apply(formModel, arguments).done(function() {
              formModel.push();
            });
          } else if (this.isTrackingAnyObjectModel()) {
            this.__saveToModels(deferred, options);
            return deferred.promise();
          } else {
  • ¶

    Return a response that is generated when this form model is not tracking an object model

            notTrackingResponse = {
              'none': {
                success: false,
                response: [{
                  responseJSON: {
                    generalReasons: [{messageKey: 'no.models.were.bound.to.form'}]
                  }
                }]
              }
            };
            this.trigger('save-fail', notTrackingResponse);
            return (new $.Deferred()).reject(notTrackingResponse).promise();
          }
        },
    
        /**
         * @returns true if this form model is backed by an Object model. That means that at least one object model was bound to an mapping alias.
         */
        isTrackingAnyObjectModel: function() {
          return _.size(this.__currentObjectModels) > 0;
        },
    
        /**
         * @returns true if any updates to an object model will immediately copy new values into this form model.
         */
        isUpdating: function() {
          return this.__currentUpdateEvents.length > 0;
        },
    
        /**
         * Will add listeners that will automatically pull new updates from this form's object models.
         * @param {boolean} [pullFirst=false] if true, the form model will pull most recent values then start listening
         */
        startUpdating: function(pullFirst) {
          if (this.isTrackingAnyObjectModel() && !this.isUpdating()) {
            if (pullFirst) {
              this.pull();
            }
            this.__setupListeners();
          }
        },
    
        /**
         * This will stop the form model from listening to its object models.
         */
        stopUpdating: function() {
          _.each(this.__currentUpdateEvents, function(eventConfig) {
            this.stopListening(eventConfig.model, eventConfig.eventName);
          }, this);
          this.__currentUpdateEvents = [];
        },
    
        /**
         * If updating, it will reset the updating events to match the current mappings.
         */
        resetUpdating: function() {
          if (this.isUpdating()) {
            this.stopUpdating();
            this.startUpdating();
          }
        },
    
        /**
         * @param {Backbone.Model} model the backbone model that is being checked
         * @param {Object} [staleModels] a hash that will be updated to contain this model if it is stale in the form: cid -> model.
         * @param {Object} [currentHashValues] If passed an object, it will look in this cache for the current value of the object model
         *   instead of calculating it. It should be key'ed by the model's cid
         * @returns {boolean} true if the model passed in has been changed since the last pull from the object model.
         */
        isModelStale: function(model, staleModels, currentHashValues) {
          var hashValue;
          currentHashValues = currentHashValues || {};
          if (!currentHashValues[model.cid]) {
            currentHashValues[model.cid] = this.__generateHashValue(model);
          }
          hashValue = currentHashValues[model.cid];
          var isStaleModel = this.__cache[model.cid] !== hashValue;
          if (staleModels) {
            if (isStaleModel) {
              staleModels[model.cid] = model;
            } else if (staleModels[model.cid]) {
              delete staleModels[model.cid];
            }
          }
          return isStaleModel;
        },
    
        /**
         * @returns {Array} an array of the object models that have been updated since the last pull from this form model
         */
        checkIfModelsAreStale: function() {
          var staleModels = {},
            currentHashValues = this.__generateAllHashValues();
          _.each(this.getTrackedModels(), function(model) {
            this.isModelStale(model, staleModels, currentHashValues);
          }, this);
          return _.values(staleModels);
        },
  • ¶

    ** Private methods **//

        /**
         * Sets up a listener to update the form model if the model's field (or any field) changes.
         * @param {Backbone.Model} model the object model from which this form model will start listen to changes
         * @param {string} [field] the field name that it will start listening to. If no field is given, it will listen to the general 'change' event.
         * @private
         */
        __listenToModelField: function(model, field) {
          var callback, eventName;
          if (field) {
            eventName = 'change:' + field;
            callback = _.bind(this.__updateFormField, {
              formModel: this,
              field: field
            });
          } else {
            eventName = 'change';
            callback = this.__updateFormModel;
          }
          this.listenTo(model, eventName, callback);
          this.__currentUpdateEvents.push({model: model, eventName: eventName});
        },
    
        /**
         * Sets up a listener on one (or all) of the fields that is needed to update a computed value
         * @param {Backbone.Model} model the object model from which this form model will start listen to changes
         * @param {string} [field] the field name that it will start listening to. If no field is given, it will listen to the general 'change' event.
         * @param {string} computedAlias the name/alias of the computed mapping being used.
         * @private
         */
        __listenToComputedValuesDependency: function(model, field, computedAlias) {
          var callback, eventName;
          if (field) {
            eventName = 'change:' + field;
          } else {
            eventName = 'change';
          }
          callback = _.bind(this.__invokeComputedPull, {
            formModel: this,
            alias: computedAlias
          });
          this.listenTo(model, eventName, callback);
          this.__currentUpdateEvents.push({model: model, eventName: eventName});
        },
    
        /**
         * Returns the models that a currently being tracked that are part of a computed mapping
         * If there is a missing model (a model alias is referenced but no model instance is bound to that alias), then it will return undefined.
         * @param {string} computedAlias the name/alias of the computed mapping
         * @returns {Object} a map from model name/alias to model instance. If there is a missing model (an model alias is referenced but no model
         *   instance is bound to that alias), then it will return undefined.
         * @private
         */
        __getComputedModels: function(computedAlias) {
          var hasAllModels = !_.isUndefined(this.getMapping(computedAlias)),
            models = {};
          _.each(this.__getModelAliases(computedAlias), function(modelAlias) {
            var model = this.getTrackedModel(modelAlias);
            if (model) {
              models[modelAlias] = model;
            } else {
              hasAllModels = false;
            }
          }, this);
          return hasAllModels ? models : undefined;
        },
    
        /**
         * Returns the aliases/names of models referenced in the computed mapping with the given alias
         * @param {(string|Object)} computedAliasOrConfig the name/alias of the computed mapping or the computed mapping itself as
         *   an object if it hasn't been added as a mapping yet.
         * @returns {string[]} an array of the model names/aliases referenced inside the computed mapping
         * @private
         */
        __getModelAliases: function(computedAliasOrConfig) {
          var config,
            modelAliases = [];
          if (_.isString(computedAliasOrConfig)) {
            config = this.getMapping(computedAliasOrConfig);
          } else {
            config = computedAliasOrConfig;
          }
          return _.filter(_.keys(config.mapping), function(key) {
            return key != 'pull' && key != 'push';
          });
        },
    
        /**
         * Repackages a computed mapping to be easier consumed by methods wanting the model mappings tied to the model instances.
         * Returns a list of objects that contain the model instance and the mapping for that model.
         *
         * @private
         * @param {string} computedAlias the name/alias used for this computed
         * @returns {object[]} a list of objects that contain the model instance under "model" and the mapping for that model under "fields".
         */
        __getComputedModelConfigs: function(computedAlias) {
          var hasAllModels = true,
            config = this.getMapping(computedAlias),
            modelConfigs = [];
          _.each(this.__getModelAliases(computedAlias), function(modelAlias) {
            var modelConfig = this.__createModelConfig(modelAlias, config.mapping[modelAlias]);
            if (modelConfig) {
              modelConfigs.push(modelConfig);
            } else {
              hasAllModels = false;
            }
          }, this);
          return hasAllModels ? modelConfigs : undefined;
        },
    
        /**
         * Pushes the form model values to the object models it is tracking and invokes save on each one. Returns a promise.
         * @param {Object} [options]
         *   @param {boolean} [options.rollback=true] if true, when any object model fails to save, it will revert the object
         *     model attributes to the state they were before calling save. NOTE: if there are updates that happen
         *     to object models within the timing of this save method, the updates could be lost.
         *   @param {boolean} [options.force=true] if false, the form model will check to see if an update has been made
         *     to any object models it is tracking since it's last pull. If any stale data is found, save with throw an exception
         *     with attributes: {name: 'Stale data', staleModels: [Array of model cid's]}
         * @returns a promise that will either resolve when all the models have successfully saved in which case the context returned
         *   is an array of the responses (order determined by first the array of models and then the array of models used by
         *   the computed values, normalized), or if any of the saves fail, the promise will be rejected with an array of responses.
         *   Note: the size of the failure array will always be one - the first model that failed. This is a side-effect of $.when
         * @private
         */
        __saveToModels: function(deferred, options) {
          var staleModels,
            formModel = this,
            responsesSucceeded = 0,
            responsesFailed = 0,
            responses = {},
            oldValues = {},
            models = formModel.getTrackedModels(),
            numberOfSaves = models.length;
  • ¶

    If we’re not forcing a save, then throw an error if the models are stale

          if (!options.force) {
            staleModels = formModel.checkIfModelsAreStale();
            if (staleModels.length > 0) {
              throw {
                name: 'Stale data',
                staleModels: staleModels
              };
            }
          }
  • ¶

    Callback for each response

          function responseCallback(response, model, success) {
  • ¶

    Add response to a hash that will eventually be returned through the promise

            responses[model.cid] = {
                success: success,
                response: response
              };
  • ¶

    If we have reached the total of number of expected responses, then resolve or reject the promise

            if (responsesFailed + responsesSucceeded === numberOfSaves) {
              if (responsesFailed > 0) {
  • ¶

    Rollback if any responses have failed

                if (options.rollback) {
                  _.each(formModel.getTrackedModels(), function(model) {
                    model.set(oldValues[model.cid]);
                    if (responses[model.cid].success) {
                      model.save();
                    }
                  });
                }
                formModel.trigger('save-fail', responses);
                deferred.reject(responses);
              } else {
                formModel.trigger('save-success', responses);
                deferred.resolve(responses);
              }
            }
          }
  • ¶

    Grab the current values of the object models

          _.each(models, function(model) {
            oldValues[model.cid] = formModel.__getTrackedModelFields(model);
          });
  • ¶

    Push the form model values to the object models

          formModel.push();
  • ¶

    Call save on each object model

          _.each(models, function(model) {
            model.save().fail(function() {
              responsesFailed++;
              responseCallback(arguments, model, false);
            }).done(function() {
              responsesSucceeded++;
              responseCallback(arguments, model, true);
            });
          });
        },
    
        /**
         * Pulls in new information from tracked models using the mapping defined by the given alias.
         * This works for both model mappings and computed value mappings
         * @param {string} alias the name of the mapping that will be used during the pull
         * @private
         */
        __pull: function(alias) {
          var config = this.getMapping(alias);
          if (config.computed && config.mapping.pull) {
            this.__invokeComputedPull.call({formModel: this, alias: alias});
          } else if (config.computed) {
            var modelAliases = this.__getModelAliases(alias);
            _.each(modelAliases, function(modelAlias) {
              var model = this.getTrackedModel(modelAlias);
              if (model) {
                this.__copyFields(config.mapping[modelAlias], this, model);
              }
            }, this);
          } else {
            var model = this.getTrackedModel(alias);
            if (model) {
              this.__copyFields(config.mapping, this, model);
            }
          }
        },
    
        /**
         * Pushes form model information to tracked models using the mapping defined by the given alias.
         * This works for both model mappings and computed value mappings
         * @param {string} alias the name of the mapping that will be used during the push
         * @private
         */
        __push: function(alias) {
          var config = this.getMapping(alias);
          if (config.computed && config.mapping.push) {
            var models = this.__getComputedModels(alias);
            if (models) {
              config.mapping.push.call(this, models);
            }
          } else if (config.computed) {
            var modelAliases = this.__getModelAliases(alias);
            _.each(modelAliases, function(modelAlias) {
              var model = this.getTrackedModel(modelAlias);
              if (model) {
                this.__copyFields(config.mapping[modelAlias], model, this);
              }
            }, this);
          } else {
            var model = this.getTrackedModel(alias);
            if (model) {
              this.__copyFields(config.mapping, model, this);
            }
          }
        },
    
        /**
         * Updates a single attribute in this form model.
         * NOTE: requires the context of this function to be:
         * {
         *  formModel: <this form model>,
         *  field: <the field being updated>
         * }
         * NOT the form model itself like if you called this.__updateFormField.
         * @private
         */
        __updateFormField: function(model, value) {
          this.formModel.set(this.field, value);
          this.formModel.__updateCache(model);
        },
    
        /**
         * NOTE: When looking to update the form model manually, call this.pull().
         * Updates this form model with the changed attributes of a given object model
         * @param {external:Backbone-Model} model the object model that has been changed
         * @private
         */
        __updateFormModel: function(model) {
          _.each(model.changedAttributes(), function(value, fieldName) {
            this.set(fieldName, this.__cloneVal(value));
          }, this);
          this.__updateCache(model);
        },
    
        /**
         * Updates the form model's snapshot of the model's attributes to use later
         * @param {external:Backbone-Model} model the object model
         * @param {Object} [cache=this.__cache] if passed an object (can be empty), this method will fill
         *   this cache object instead of this form model's __cache field
         * @private
         */
        __updateCache: function(model) {
          if (!model) {
            this.__cache = {};
            _.each(this.getTrackedModels(), function(model) {
              if (model) {
                this.__updateCache(model);
              }
            }, this);
          } else {
            this.__cache[model.cid] = this.__generateHashValue(model);
          }
        },
    
        /**
         * Create a hash value of a simple object
         * @param {Object} obj simple object with no functions
         * @returns a hash value of the object
         * @private
         */
        __hashValue: function(obj) {
          return JSON.stringify(obj);
        },
    
        /**
         * Returns the alias/name bound to the model passed in. If a string is passed in, it will just return this string.
         * @param {string|external:Backbone-Model} aliasOrModel If string, just returns this string. If a model instance, then the alias
         *   that is bound to the tracked model passed in will be found and returned.
         * @returns {string} the alias
         * @private
         */
        __findAlias: function(aliasOrModel) {
          var alias, objectModel;
          if (_.isString(aliasOrModel)) {
            alias = aliasOrModel;
          } else {
            objectModel = aliasOrModel;
            alias = _.find(this.__currentObjectModels, function(model) {
              return model == objectModel;
            });
          }
          return alias;
        },
    
        /**
         * @param {external:Backbone-Model} model the model to create the hash value from
         * @returns {string} the hash value of the model making sure to only use the tracked fields
         * @private
         */
        __generateHashValue: function(model) {
          var modelFields = this.__getTrackedModelFields(model);
          return this.__hashValue(modelFields);
        },
    
        /**
         * @returns {Object} a map of model's cid to the hash value of the model making sure to only use the tracked fields
         * @private
         */
        __generateAllHashValues: function() {
          var currentHashValues = {};
          _.each(this.getTrackedModels(), function(model) {
            currentHashValues[model.cid] = this.__generateHashValue(model);
          }, this);
          return currentHashValues;
        },
    
        /**
         * Deep clones the attributes. There should be no functions in the attributes
         * @param {(Object|Array|string|number|boolean)} val a non-function value
         * @returns the clone
         * @private
         */
        __cloneVal: function(val) {
          var seed;
          if (_.isArray(val)) {
            seed = [];
          } else if (_.isObject(val)) {
            seed = {};
          } else {
            return val;
          }
          return $.extend(true, seed, val);
        },
    
        /**
         * Attaches listeners to the tracked object models with callbacks that will copy new properties into this form model.
         * @private
         */
        __setupListeners: function() {
          var model, modelConfigs,
            formModel = this;
          _.each(formModel.getMappings(), function(config, alias) {
            if (config.computed) {
              modelConfigs = formModel.__getComputedModelConfigs(alias);
              _.each(modelConfigs, function(modelConfig) {
                var model = modelConfig.model;
                if (modelConfig.fields) {
                  _.each(modelConfig.fields, function(field) {
                    formModel.__listenToComputedValuesDependency(model, field, alias);
                  });
                } else {
                  formModel.__listenToComputedValuesDependency(model, '', alias);
                }
              });
            } else {
              model = formModel.getTrackedModel(alias);
              if (model) {
                if (config.mapping) {
                  _.each(config.mapping, function(field) {
                    formModel.__listenToModelField(model, field);
                  });
                } else {
                  formModel.__listenToModelField(model);
                }
              }
            }
          });
        },
    
        /**
         * Copies fields from one backbone model to another. Is useful during a pull or push to/from Object models. The values will
         * be deep cloned from the origin to the destination.
         * @param {Array} [fields] a string of attribute names on the origin model that will be copied. Leave null if all attributes
         *   are to be copied
         * @param {Backbone.Model} destination the backbone model that will have values copied into
         * @param {Backbone.Model} origin the backbone model that will be used to grab values.
         * @private
         */
        __copyFields: function(fields, destination, origin) {
          if ((!fields || fields === true) && this === origin && _.size(this.getTrackedModels()) > 1) {
  • ¶

    only copy attributes that exist on object model when the form model is tracking all the properties of that object model, but is also tracking other models as well.

            fields = _.keys(destination.attributes);
          }
          if (fields) {
            _.each(fields, function(field) {
              destination.set(field, this.__cloneVal(origin.get(field)));
            }, this);
          } else {
            destination.set(this.__cloneVal(origin.attributes));
          }
        },
    
        /**
         * Sets the mapping using the form model's default mapping or the options.mappings if available.
         * Also sets the tracked models if the form model's default models or the options.models is provided.
         * @param {Object} [options] See initialize options: 'mapping' and 'models'.
         * @private
         */
        __initMappings: function(options) {
          var mapping,
            models,
            defaultMapping = _.result(this, 'mapping'),
            defaultModels = _.result(this, 'models');
          mapping = options.mapping || defaultMapping;
          models = options.models || defaultModels;
          if (mapping) {
            this.setMappings(mapping, models);
          }
        },
    
        /**
         * Returns a map where the keys are the fields that are being tracked on tracked model and values are
         * the with current values of those fields.
         * @param {external:Backbone-Model} model the object model
         * @returns {Object} aa map where the keys are the fields that are being tracked on tracked model and
         *   values are the with current values of those fields.
         * @private
         */
        __getTrackedModelFields: function(model) {
          var allFields,
            fieldsUsed = {},
            modelFields = {},
            modelConfigs = [];
          _.each(this.__getAllModelConfigs(), function(modelConfig) {
            if (modelConfig.model && modelConfig.model.cid === model.cid) {
              modelConfigs.push(modelConfig);
            }
          });
          allFields = _.reduce(modelConfigs, function(result, modelConfig) {
            return result || !modelConfig.fields;
          }, false);
          if (allFields) {
            modelFields = this.__cloneVal(model.attributes);
          } else {
            _.each(modelConfigs, function(modelConfig) {
              _.each(modelConfig.fields, function(field) {
                if (!fieldsUsed[field]) {
                  fieldsUsed[field] = true;
                  modelFields[field] = this.__cloneVal(model.get(field));
                }
              }, this);
            }, this);
          }
          return modelFields;
        },
    
        /**
         * Returns a useful data structure that binds a tracked model to the fields being tracked on a mapping.
         * @param modelAlias
         * @param {(string[]|undefined)} fields the fields that the model is tracking. Can be undefined if tracking all fields.
         *   When creating a model config for a computed mapping, the fields refers to the fields being tracked only for that computed value.
         * @returns {Object} a binding between a tracked model and the fields its tracking for a mapping. If no tracked model is bound to the modelAlias,
         *   it will return undefined.
         * @private
         */
        __createModelConfig: function(modelAlias, fields) {
          var model = this.getTrackedModel(modelAlias);
          if (model) {
            return {
              fields: fields,
              model: model
            };
          }
        },
    
        /**
         * Returns an array of convenience data structures that bind tracked models to the fields they are tracking for each mapping,
         * including model mappings inside computed mappings. There will be a model config for each tracked model on a computed mapping
         * meaning there can be multiple model configs for the same tracked model.
         * @returns {Array} array of convenience data structures that bind tracked models to the fields they are tracking for each mapping,
         *   including model mappings inside computed mappings.
         * @private
         */
        __getAllModelConfigs: function() {
          var modelConfigs = [];
          _.each(this.getMappings(), function(config, alias) {
            if (config.computed) {
              var computedModelConfigs = this.__getComputedModelConfigs(alias);
              if (computedModelConfigs) {
                modelConfigs = modelConfigs.concat(computedModelConfigs);
              }
            } else {
              var modelConfig = this.__createModelConfig(alias, config.mapping);
              if (modelConfig) {
                modelConfigs.push(modelConfig);
              }
            }
          }, this);
          return modelConfigs;
        },
    
        /**
         * A wrapper function that can invoke the pull callback on a computed mapping during an event callback.
         * Because an event callback predetermines the argument list, this method assumes the necessary computed configuration is
         * bound as the part of the function context.
         * When invoking the pull callback, it will pass in a object map from model alias to shallow copy of the tracked fields the
         * computed value uses. It is NOT just the model, but a  copy of its attributes - feel free to change the properties.
         * Example:
         * fooBar: {
         *   myModel: 'foo bar',
         *   pull: function(models) {
         *     console.log(models.myModel.foo, models.myModel.bar)
         *   }
         * }
         * If any model mapping is tracking all fields by passing true as its config, a copy of all the attributes for that model will be provided.
         * @param {Backbone.Model} [model] the model that was updated. If provided, the cache will be updated
         * NOTE: requires the context of this function to be:
         * {
         *  formModel: <this form model>,
         *  alias: <the computed alias>,
         * }
         * @private
         */
        __invokeComputedPull: function(model) {
          if (model) {
            this.formModel.__updateCache(model);
          }
          (function(formModel, alias) {
            var hasAllModels = true,
              config = formModel.getMapping(alias),
              modelAliases = formModel.__getModelAliases(alias),
              models = {};
            if (!config.mapping.pull) {
              if (console && _.isFunction(console.log)) {
                console.log('Not pulling the computed: ' + alias + ', because no pull method was defined for this computed.');
              }
              return;
            }
            _.each(modelAliases, function(modelAlias) {
              var fields = config.mapping[modelAlias],
                model = formModel.getTrackedModel(modelAlias),
                modelCopy = {};
              if (!model) {
                hasAllModels = false;
              } else {
                if (fields) {
                  _.each(fields, function(field) {
                    modelCopy[field] = formModel.__cloneVal(model.get(field));
                  });
                } else {
                  modelCopy = formModel.__cloneVal(model.attributes);
                }
                models[modelAlias] = modelCopy;
              }
            });
            if (hasAllModels) {
              config.mapping.pull.call(formModel, models);
            }
          })(this.formModel, this.alias);
        }
      });
    
      _.extend(FormModel.prototype, validation.mixin);
    
      return FormModel;
    }));