• 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
  • DataBehavior.js

  • ¶
    (function(root, factory) {
      if (typeof define === 'function' && define.amd) {
        define(['underscore', 'backbone', '../Behavior', '../Collection', '../Events'], factory);
      } else if (typeof exports === 'object') {
        var _ = require('underscore');
        var Backbone = require('backbone');
        var Behavior = require('../Behavior');
        var Collection = require('../Collection');
        var Events = require('../Events');
        module.exports = factory(_, Backbone, Behavior, Collection, Events);
      } else {
        root.Torso = root.Torso || {};
        root.Torso.behaviors = root.Torso.behaviors || {};
        root.Torso.behaviors.DataBehavior = factory(root._, root.Backbone, root.Torso.Behavior, root.Torso.Collection, root.Torso.Events);
      }
    }(this, function(_, Backbone, Behavior, Collection, Events) {
      'use strict';
    
      var $ = Backbone.$;
    
      var PROPERTY_SEPARATOR = '.';
      var CONTAINER_SEPARATOR = ':';
    
      var FETCHED_STATUSES = {
        SUCCESS: 'success',
        FAILURE: 'failed'
      };
    
      /**
       * Converts string or number values into an array with a single string or number item.
       * If the input is not a string, number, array, or info about the ids then undefined is returned.
       * This is a private helper method used internally by this behavior and is not exposed in any way.
       * @param {(string|number|string[]|number[]|Object)} ids the ids to convert.
       *   @param {boolean} [ids.skipObjectRetrieval] set if this is a meta-info object about the ids.
       * @returns {(string[]|number[]|Object)} an array of strings or numbers.
       * @private
       */
      function normalizeIds(ids) {
        if (_.isArray(ids)) {
  • ¶

    remove any nesting of arrays - it is assumed that the resulting ids will be simple string or number values.

          ids = _.flatten(ids);
  • ¶

    remove any duplicate ids.

          return _.uniq(ids);
        } else if (_.isString(ids) || _.isNumber(ids)) {
  • ¶

    individual id - convert to array for consistency.

          return [ids];
        } else if (ids && ids.skipObjectRetrieval) {
          return ids;
        }
      }
    
      /**
       * Converts any undefined or null values to an empty array.  All other values are left unchanged.
       * @param valueToConvert the value to check for null or undefined.
       * @returns {Array|*} either the original value or [] if the valueToConvert is null or undefined.
       */
      function undefinedOrNullToEmptyArray(valueToConvert) {
        if (_.isUndefined(valueToConvert) || _.isNull(valueToConvert)) {
          valueToConvert = [];
        }
        return valueToConvert;
      }
    
      /**
       * Gets a nested property from an object, returning undefined if it doesn't exist on any level.
       * @param {Object} rootObject object containing the property to get.
       * @param {string} propertyString string identifying the nested object to retrieve.
       * @returns {*} either undefined or the property referenced from the rootObject.
       */
      function getNestedProperty(rootObject, propertyString) {
        propertyString = propertyString.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
        propertyString = propertyString.replace(/^\./, '');           // strip a leading dot
        var propertyStringParts = propertyString.split(PROPERTY_SEPARATOR);
        return _.reduce(propertyStringParts, function(currentBaseObject, currentPropertyName) {
          return _.isUndefined(currentBaseObject) ? undefined : currentBaseObject[currentPropertyName];
        }, rootObject);
      }
    
      /**
       * Determines if the stringDefinition contains a container definition (i.e. has a ':' in it).
       * @param stringDefinition to test.
       * @returns {boolean} true if the string definition contains a container definition, false otherwise.
       */
      function containsContainerDefinition(stringDefinition) {
        return !!stringDefinition && stringDefinition.indexOf(CONTAINER_SEPARATOR) > -1;
      }
    
      /**
       * This behavior implements simplified interaction with data sources (i.e. TorsoCollection).
       * This behavior manages re-rendering when data changes and automatically adding the returned data to the view's context.
       * This behavior also manages dependencies between data and other objects to allow intelligent re-fetching when data changes.
       *
       * @class DataBehavior
       * @extends Behavior
       *
       * @author  jyoung@vecna.com
       *
       * @see <a href="../annotated/modules/behaviors/DataBehavior.html">DataBehavior Annotated Source</a>
       * @see <a href="../modules/behaviors/DATA_BEHAVIOR.html">Detailed docs</a> for more in-depth documentation and details.
       */
      var DataBehavior = Behavior.extend(/** @lends DataBehavior */{
        /**
         * The torso collection that is acting as a cache used to create the private collections.
         * This property/option is required.  Instantiation will fail if it is not set.
         * @property {Collection} cache
         */
        cache: undefined,
    
        /**
         * Adds a listener on the Behavior for the `fetched` event that triggers a render on the view.
         * true - A listener is added to the behavior that re-renders the view when a 'fetched' event is triggered.
         * false (default) - no listeners are added.
         * @property {boolean} renderOnFetch
         * @default false
         */
        renderOnFetch: false,
    
        /**
         * Skip triggering a load of this data behavior when the view completes initializing.
         * true - no load after the view is initialized.
         * false (default) - trigger a .retrieve() on this data behavior when the view completes initialization.
         * @property {boolean} skipInitialLoad
         * @default false
         */
        skipInitialLoad: false,
    
        /**
         * Determines the result of `view.getBehavior('thisBehaviorAlias').toJSON()`.
         * true - a single model result is returned.
         * false (default) - an array of model results are returned.
         * @property {boolean} returnSingleResult
         * @default false
         */
        returnSingleResult: false,
    
        /**
         * Determines whether `pull()` or `fetch()` is called when using `retrieve()`.
         * true - Use fetch() by default on the private collection.
         * false (default) - Use pull() by default on the private collection.
         * True will query the server more often, but will provide more up-to-date data.
         * False will only query the server if the model hasn't already been retrieved.
         * This property will be ignored if `fetch()` or `pull()` is called directly.
         * @property {boolean} alwaysFetch
         * @default false
         */
        alwaysFetch: false,
    
        /**
         * Duck-typed property that identifies the ids to use. id or ids is required (either by behavior options or as properties).
         *   - {(string|number)} - the id to use directly (equivalent to an array of a single id).
         *   - {(string[]|number[])} - the ids to use directly.
         *   - {Object} - more complex configuration that identifies a model-like object that fires a change event and the
         *                property on that object to use. The object can fire the change event for the given property
         *                and have a .get('propertyName') method, or it can define the property directly on the idContainer.
         *                Only one property can be identified as supplying the id for this data model.
         *                If the identified object does not fire a change event then the id(s) will never be refreshed for this behavior.
         *                The idContainer can also fire a 'fetched:ids' event on itself to signal to this data behavior that the ids
         *                have been fetched for the first time.  Then a 'change:<propertyName>' event can be used to notify this
         *                data behavior that the property has been modified.
         *     - property {string} - the name of the property that defines the ids. The root object is assumed to be the view unless
         *                           idContainer is defined. The idContainer is the object that fires a change event for the given property name.
         *                           Uses the view or the idContainer as the root to get the identified property (i.e. 'viewState.', 'model.', etc).
         *                           Will get the property before the first '.' from the view and if it is an object will try to use a
         *                           .get('<propertyName>') on it and set a 'change:<propertyName>' listener on it.
         *                           If it is a string/number or array of string/number, then it will use that as the ids.
         *                           Triggering a 'id-container-updated' event on the behavior will cause it to stop listing to the
         *                           old idContainer and start listening to the new one defined by this property.
         *     - idContainer {Cell|Backbone.Model|Function} - object (or a function that returns an object) that fires change
         *                           events and has a .get('propertyName') function. It isn't required to fire events -
         *                           the change event is only required if it needs to re-fetch when the id property value changes.
         *     Examples:
         *       - { property: '_patientId' }
         *       - { property: 'viewState.appointmentId' }
         *       - { property: 'model.type' }
         *       - { property: 'behaviors.demographics.data.appointments' }
         *       - { property: 'id', idContainer: userService }
         *       - { property: 'username', idContainer: function() { application.getCurrentUser() } }
         *   - {Function(cache)} - expected to return the ids (either array, jquery deferred that resolves to the ids or single primitive)
         *                         to track with the private collection. Cache is passed in as the first argument so that the behavior
         *                         can be defined and the cache can be overridden later.
         *                         'this' is the behavior (from which you can get the view if needed).
         *                         What was criteria should use this instead:
         *
         *         function(cache) {
         *           var thisBehaviorInstance = this;
         *           var view = this.view;
         *           var criteria = { ... some criteria ... };
         *           return cache.fetchIdsByCriteria(criteria);
         *         }
         * @property {(string|number|string[]|number[]|Object|Function)} ids
         */
        ids: undefined,
    
        /**
         * cause this behavior to re-calculate its ids and refetch them from the server if the given events are triggered
         * (space separated if string, single item is equivalent to array of single item).
         *   - 'view:eventName' - arbitrary event triggered on the view (eventName can be a change:propertyName event).
         *   - 'viewState:eventName' - arbitrary event triggered on the viewState (eventName can be a change:propertyName event).
         *   - 'model:eventName' - arbitrary even triggered on the view's model (eventName can be a change:propertyName event).
         *   - 'this:eventName' - arbitrary event triggered by this behavior (eventName can be a change:propertyName event).
         *   - 'behaviorAlias:eventName' - arbitrary event triggered by another data behavior on this view (eventName can be a change:propertyName event).
         *   - 'behaviorAlias.data:eventName' - arbitrary event triggered by the data of another DataBehavior on this view (eventName can be a change:propertyName event).
         *   - { '<eventName>': < object (or function returning an object) that the event is triggered on > } - arbitrary ('<eventName>') triggered on the supplied object.
         * @property {(string|string[]|Object|Object[])} updateEvents
         */
        updateEvents: undefined,
    
        /**
         * Object that manages interaction with the data.  Contains the privateCollection, proxies all events from the privateCollection,
         * and has get('...') and .toJSON() methods that access the private collection data.
         * @property {Torso.behaviors.DataBehavior.Data} data
         */
        data: undefined,
    
        /**
         * The possible fetched statuses.  This is the status value of the fetched event payload.
         * @property {Object} { SUCCESS: 'SUCCESS', FAILURE: 'FAILURE' } FETCHED_STATUSES
         */
        FETCHED_STATUSES: FETCHED_STATUSES,
    
        /**
         * @override
         * @param {Object} [behaviorState] the initial state of the behavior.
         * @param {Object} behaviorOptions
         *   @param {Collection} behaviorOptions.cache see cache property.
         *   @param {boolean} [behaviorOptions.renderOnFetch=false] see renderOnFetch property.
         *   @param {boolean} [behaviorOptions.skipInitialLoad=false] see skipInitialLoad property.
         *   @param {boolean} [behaviorOptions.returnSingleResult=false] see returnSingleResult property.
         *   @param {boolean} [behaviorOptions.alwaysFetch=false] see alwaysFetch property.
         *   @param {string|number|string[]|number[]|{property: String, idContainer: Object}|Function} [behaviorOptions.id=behaviorOptions.ids] see id property.
         *   @param {string|number|string[]|number[]|{property: String, idContainer: Object}|Function} [behaviorOptions.ids=behaviorOptions.id] see ids property.
         *   @param {(string|string[]|Object|Object[])} [behaviorOptions.updateEvents] see updateEvents property.
         * @param {Object} [viewOptions] options passed to View's initialize
         */
        constructor: function(behaviorState, behaviorOptions, viewOptions) {
          _.bindAll(this, '__skipRetrieveOnEmptyTrackedIdsAndNewIds', '__completeLoadingIds', '__fetchSuccess', '__fetchFailed', '__abortIfDisposed');
          behaviorOptions = behaviorOptions || {};
          behaviorOptions = _.defaults(behaviorOptions, {
            alwaysFetch: false
          });
          _.extend(this, _.pick(behaviorOptions, 'cache', 'id', 'ids', 'renderOnFetch', 'skipInitialLoad', 'returnSingleResult', 'alwaysFetch', 'updateEvents'));
    
          this.__validateCache();
          this.__normalizeAndValidateIds();
          this.__normalizeAndValidateUpdateEvents();
    
          this.cid = this.cid || _.uniqueId(this.cidPrefix);
          this.data = new this.Data({
            parentBehavior: this,
            privateCollection: this.cache.createPrivateCollection(this.cid)
          });
    
          Behavior.apply(this, arguments);
    
          this.set('loadingIds', 0);
    
          this.on('id-container-updated', this.listenToIdsPropertyChangeEvent);
          this.on('id-container-updated', this.retrieve);
          this.listenTo(this.view, 'initialize:complete', this.listenToIdsPropertyChangeEvent);
          this.listenTo(this.view, 'initialize:complete', this._delegateUpdateEvents);
          if (!this.skipInitialLoad) {
            this.listenTo(this.view, 'initialize:complete', this.retrieve);
          }
  • ¶

    This allows ‘renderOnFetch’ to be changed at runtime after the constructor is executed.

          this.on('fetched', function() {
  • ¶

    If a view isn’t active then it shouldn’t be rendering. When a view is attached it is rendered anyway.

            if (this.renderOnFetch && this.view.isActive() && this.view.isAttached()) {
              this.view.render();
            }
          });
          this.listenTo(this.view, 'before-dispose-callback', this.data.dispose);
        },
    
        /**
         * Retrieves the ids for this data object and passes them off to the private collection to track and then does a
         * pull or a fetch based on the alwaysFetch property.  (pull is default if always fetch is true then it fetches instead).
         * @returns {$.Deferred.Promise} a jquery deferred promise that resolves to the retrieved models.
         */
        retrieve: function() {
          if (this.alwaysFetch) {
            return this.fetch();
          } else {
            return this.pull();
          }
        },
    
        /**
         * Retrieves the ids for this data object and passes them off to the private collection's trackAndPull() method.
         * @returns {$.Deferred.Promise} a jquery deferred promise that resolves to the retrieved models.
         */
        pull: function() {
          var thisDataBehavior = this;
          return this.__getIds()
            .then(this.__skipRetrieveOnEmptyTrackedIdsAndNewIds)
            .then(this.__abortIfDisposed)
            .then(function(idsResult) {
              if (idsResult && !idsResult.skipObjectRetrieval) {
                return thisDataBehavior.data.privateCollection.trackAndPull(idsResult);
              }
              return idsResult;
            }, function(errorResponse) {
              errorResponse.failedOnIds = true;
  • ¶

    Deferred reject is more reliable than throwing an exception since it works in both jQuery 2 and 3.

              return new $.Deferred().reject(errorResponse).promise();
            })
            .then(this.__fetchSuccess, this.__fetchFailed);
        },
    
        /**
         * Retrieves the ids for this data object and passes them off to the private collection's trackAndFetch() method.
         * @returns {$.Deferred.Promise} a jquery deferred promise that resolves to the retrieved models.
         */
        fetch: function() {
          var thisDataBehavior = this;
          return this.__getIds()
            .then(this.__skipRetrieveOnEmptyTrackedIdsAndNewIds)
            .then(this.__abortIfDisposed)
            .then(function(idsResult) {
              if (idsResult && !idsResult.skipObjectRetrieval) {
                return thisDataBehavior.data.privateCollection.trackAndFetch(idsResult);
              }
              return idsResult;
            }, function(errorResponse) {
              errorResponse.failedOnIds = true;
  • ¶

    Deferred reject is more reliable than throwing an exception since it works in both jQuery 2 and 3.

              return new $.Deferred().reject(errorResponse).promise();
            })
            .then(this.__fetchSuccess, this.__fetchFailed);
        },
    
        /**
         * Adds the toJSON of the data represented by this behavior to the context.
         * @override
         */
        prepare: function() {
          var behaviorContext = Behavior.prototype.prepare.apply(this) || {};
          behaviorContext.data = this.data.toJSON();
          behaviorContext.loading = this.isLoading();
          behaviorContext.loadingIds = this.isLoadingIds();
          behaviorContext.loadingObjects = this.isLoadingObjects();
          return behaviorContext;
        },
    
        /**
         * Determine if the behavior is loading objects or ids.
         * @returns {boolean} true - the behavior is currently loading objects or ids.
         *                   false - the behavior is not currently loading objects or ids.
         */
        isLoading: function() {
          return this.isLoadingIds() || this.isLoadingObjects();
        },
    
        /**
         * Determine if the behavior is loading ids.
         * @returns {boolean} true - the behavior is currently loading ids.
         *                   false - the behavior is not currently loading ids.
         */
        isLoadingIds: function() {
          return this.get('loadingIds') > 0;
        },
    
        /**
         * Determine if the behavior is loading objects.
         * @returns {boolean} true - the behavior is currently loading objects.
         *                   false - the behavior is not currently loading objects.
         */
        isLoadingObjects: function() {
          return this.data.privateCollection.isLoading();
        },
    
        /**
         * Listens for the change event on the ids property and, if triggered, re-fetches the data based on the new ids.
         */
        listenToIdsPropertyChangeEvent: function() {
          if (!_.isUndefined(this.ids.property)) {
            this.stopListeningToIdsPropertyChangeEvent();
            var idsPropertyNameAndContext = this.__parseIdsPropertyNameAndIdContainer();
            var idContainer = idsPropertyNameAndContext.idContainer;
            var canListenToEvents = idContainer && _.isFunction(idContainer.on);
            if (canListenToEvents) {
              this.__currentContextWithListener = idContainer;
              this.__currentContextEventName = 'change:' + idsPropertyNameAndContext.idsPropertyName;
              this.listenTo(this.__currentContextWithListener, this.__currentContextEventName, this.retrieve);
              this.listenTo(this.__currentContextWithListener, 'fetched:ids', this.retrieve);
            }
          }
        },
    
        /**
         * Removes the listener added by listenToIdsPropertyChangeEvent().
         */
        stopListeningToIdsPropertyChangeEvent: function() {
          if (this.__currentContextWithListener) {
            this.stopListening(this.__currentContextWithListener, this.__currentContextEventName, this.retrieve);
            this.stopListening(this.__currentContextWithListener, 'fetched:ids', this.retrieve);
            delete this.__currentContextWithListener;
            delete this.__currentContextEventName;
          }
        },
    
        /**
         * This is a good way to have something be called after at least one retrieve (pull or fetch) has completed.
         * This is especially useful if you don't care if the fetch has already happen you just want to do something once
         * the data is loaded.
         *
         * This can also be done purely by listening for the 'fetched' event, but you might miss the event if it is fired
         * before you start listening.  This gives a structure for handling that case so that your methods are called
         * if the event is fired and if it is not fired.
         *
         * This also gives the ability to distinguish between a successful and failed fetch easily using the promises
         * resolve/reject handlers.
         *
         * Usage:
         *
         * someDataBehavior.retrieveOncePromise()
         *   .then(view.doSomethingWithTheData, view.handleFiledFetch);
         *
         * @returns {external:jQuery-Deferred} that resolves when the data is successfully fetched and rejects when the fetch fails.
         */
        retrieveOncePromise: function() {
          var retrieveOnceDeferred = $.Deferred();
          var demographicsFetchSuccess = this.get('fetchSuccess');
          if (demographicsFetchSuccess) {
            retrieveOnceDeferred.resolve();
          } else if (demographicsFetchSuccess === false) {
            retrieveOnceDeferred.reject();
          } else {
            this.once('fetched', function() {
              if (this.get('fetchSuccess')) {
                retrieveOnceDeferred.resolve();
              } else {
                retrieveOnceDeferred.reject();
              }
            });
          }
          return retrieveOnceDeferred.promise();
        },
    
        /**
         * Removes existing listeners and adds new ones for all of the updateEvents configured.
         * @private
         */
        _delegateUpdateEvents: function() {
          this._undelegateUpdateEvents();
          var updateEvents = this.__parseUpdateEvents();
          _.each(updateEvents, function(parsedUpdateEvent) {
            this.listenTo(parsedUpdateEvent.idContainer, parsedUpdateEvent.eventName, this.retrieve);
          }, this);
        },
    
        /**
         * Removes existing event listeners.
         * @private
         */
        _undelegateUpdateEvents: function() {
          var updateEvents = this.__parseUpdateEvents();
          _.each(updateEvents, function(parsedUpdateEvent) {
            this.stopListening(parsedUpdateEvent.idContainer, parsedUpdateEvent.eventName, this.retrieve);
          }, this);
        },
    
        /**
         * Parses this.updateEvents configuration.
         * @returns {Object[]} {[{ eventName: String, idContainer: Object }]} an array of objects with the event name and idContainer included.
         * @private
         */
        __parseUpdateEvents: function() {
          this.__normalizeAndValidateUpdateEvents();
          var updateEvents = _.flatten(_.map(this.updateEvents, this.__parseUpdateEvent, this));
          return _.compact(updateEvents);
        },
    
        /**
         * Parses an individual event configuration.
         * Note: events defined using objects can have more than one event defined w/in the object.
         * @param {string | Object} updateEventConfiguration the configuration for an individual event configuration.
         * @returns {(Object[]|undefined)} {[{ eventName: String, idContainer: Object }] | undefined} an array of objects with the event name and idContainer included.
         *                                                                If the event could not be parsed, undefined is returned.
         * @private
         */
        __parseUpdateEvent: function(updateEventConfiguration) {
          if (_.isUndefined(updateEventConfiguration)) {
            return undefined;
          }
          var parsedUpdateEvents = [];
          if (_.isString(updateEventConfiguration)) {
            var updateEvent = this.__parseStringUpdateEvent(updateEventConfiguration);
            if (!_.isUndefined(updateEvent)) {
              parsedUpdateEvents.push(updateEvent);
            }
          } else if (_.isObject(updateEventConfiguration)) {
            parsedUpdateEvents = _.map(updateEventConfiguration, function(idContainer, eventName) {
              return {
                idContainer: idContainer,
                eventName: eventName
              };
            });
          }
          return parsedUpdateEvents;
        },
    
        /**
         * Validates that the cache property is valid and if not throws an error describing why its not valid.
         * @private
         */
        __validateCache: function() {
          if (!this.cache) {
            throw new Error('Torso Data Behavior constructed without a cache');
          }
          if (!(this.cache instanceof Collection)) {
            throw new Error('Torso Data Behavior\'s cache is not of type Torso.Collection');
          }
        },
    
        /**
         * Validates that the ids property is valid and if not throws an error describing why its not valid.
         * A side effect of this method is copying id into the ids location (if id is set).  Ids is what is used by the rest of the code.
         * This is done as part of validation because we first validate that both are not set.
         * @private
         */
        __normalizeAndValidateIds: function() {
          if (!_.isUndefined(this.ids) && !_.isUndefined(this.id)) {
            throw new Error('Torso Data Behavior constructed with both id and ids.  Please define only one.');
          }
          this.ids = this.id || this.ids;
          this.__validateIds();
        },
    
        /**
         * Validates that the ids property is valid and if not throws an error describing why its not valid.
         * A side effect of this method is copying id into the ids location (if id is set).  Ids is what is used by the rest of the code.
         * This is done as part of validation because we first validate that both are not set.
         * @private
         */
        __validateIds: function() {
          if (_.isUndefined(this.ids)) {
            throw new Error('Torso Data Behavior constructed without a way to identify the ids for this data.  Please define either id, ids.');
          }
    
          var idsIsArray = _.isArray(this.ids);
          var idsIsSingleId = _.isString(this.ids) || _.isNumber(this.ids);
          var idsIsFunction = _.isFunction(this.ids);
          var idsIsObjectWithStringProperty = _.isString(this.ids.property);
          var idsIsObject = _.isObject(this.ids);
          var idsIsValid = idsIsArray || idsIsSingleId || idsIsFunction || idsIsObjectWithStringProperty;
          if (!idsIsValid && idsIsObject) {
            throw new Error('Data Behavior ids invalid definition.  It is an object, but the property field is not defined or is not a string: ' + JSON.stringify(this.ids));
          } else if (!idsIsValid) {
            throw new Error('Data Behavior ids invalid definition.  Not a string, number, object, array or function: ' + JSON.stringify(this.ids));
          }
    
          if (idsIsObjectWithStringProperty) {
            var propertyNameContainsIdContainer = containsContainerDefinition(this.ids.property);
            var hasIdContainerProperty = !_.isUndefined(this.ids.idContainer);
    
            if (hasIdContainerProperty && propertyNameContainsIdContainer) {
              throw new Error('Data Behavior ids invalid definition.  Id container defined on both ids.property and ids.idContainer: ', JSON.stringify(this.ids));
            }
          }
        },
    
        /**
         * Validates that the updateEvents property is valid and if not throws an error describing why its not valid.
         * @private
         */
        __normalizeAndValidateUpdateEvents: function() {
          var updateEventsIsArray = _.isArray(this.updateEvents);
          var updateEventsIsSingleValue = !updateEventsIsArray && (_.isObject(this.updateEvents) || _.isString(this.updateEvents));
          var updateEventsIsUndefined = _.isUndefined(this.updateEvents);
          var updateEventsIsValidType = updateEventsIsArray || updateEventsIsSingleValue || updateEventsIsUndefined;
    
          if (updateEventsIsSingleValue) {
            this.updateEvents = [this.updateEvents];
          }
    
          if (!updateEventsIsValidType) {
            throw new Error('Update events are not an array, string or object.  Please see parameters for examples of how to define updateEvents.  Configured UpdateEvents: ', this.updateEvents);
          }
  • ¶

    Remove any random falsey values (mostly to get rid of undefined events).

          this.updateEvents = _.compact(this.updateEvents);
          _.each(this.updateEvents, this.__validUpdateEvent);
        },
    
        /**
         * Validates that the updateEventConfiguration is valid and if not throws an error describing why its not valid.
         * @private
         */
        __validUpdateEvent: function(updateEventConfiguration) {
          var validStringConfig = _.isString(updateEventConfiguration);
          var validObjectConfig = _.isObject(updateEventConfiguration) && _.keys(updateEventConfiguration).length > 0;
          if (!validStringConfig && !validObjectConfig) {
            throw new Error('Not a valid updateEvent configuration.  Update events need to either be strings or objects with a single property: ' + JSON.stringify(updateEventConfiguration));
          }
        },
    
        /**
         * @returns {$.Deferred.Promise} A jquery deferred promise that resolves to the ids to track in the private collection
         *                              or rejects with the error message.
         *                              It can also return an object that prevents object retrieval ({ skipObjectRetrieval: true }).
         * @private
         */
        __getIds: function() {
          this.set('loadingIds', this.get('loadingIds') + 1);
          this.__validateIds(); // validate ids enforces a fast-fail that guarantees that one of the if statements below will work.
    
          var idsDeferred = $.Deferred();
          var ids = this.ids;
          var normalizedIds = normalizeIds(ids);
          if (!_.isUndefined(normalizedIds)) {
            idsDeferred.resolve(normalizedIds);
          } else if (_.isFunction(this.ids)) {
            ids = this.ids(this.cache);
            normalizedIds = normalizeIds(ids);
            if (!_.isUndefined(normalizedIds)) {
              idsDeferred.resolve(normalizedIds);
            } else if (ids && _.isFunction(ids.then)) {
              idsDeferred = ids.then(normalizeIds);
            } else {
              idsDeferred.resolve([]);
            }
          } else if (!_.isUndefined(this.ids.property)) {
            var parsedContextDefinition = this.__parseIdsPropertyNameAndIdContainer();
            var propertyName = parsedContextDefinition.idsPropertyName;
            var idContainer = parsedContextDefinition.idContainer;
    
            ids = getNestedProperty(idContainer, propertyName);
            var propertyOnContextIsUndefined = idContainer && _.isUndefined(ids);
            var idContainerHasGetMethod = idContainer && _.isFunction(idContainer.get);
    
            if (propertyOnContextIsUndefined && idContainerHasGetMethod) {
              ids = idContainer.get(propertyName);
            }
            normalizedIds = normalizeIds(ids);
    
            idsDeferred.resolve(normalizedIds);
          }
          return idsDeferred.promise()
            .then(undefinedOrNullToEmptyArray)
            .always(this.__completeLoadingIds);
        },
    
        /**
         * Sets the loading ids property to false (loading completed).
         * @private
         */
        __completeLoadingIds: function() {
          this.set('loadingIds', this.get('loadingIds') - 1);
        },
    
        /**
         * Converts the definition into the actual idContainer object and property name to retrieve off of that idContainer.
         * @returns {{idsPropertyName: String, idContainer: Object}} the name of the ids property and the actual object to use as the idContainer.
         * @private
         */
        __parseIdsPropertyNameAndIdContainer: function() {
          var propertyName = this.ids.property;
          var propertyNameContainsIdContainer = containsContainerDefinition(propertyName);
          var hasIdContainerProperty = !_.isUndefined(this.ids.idContainer);
    
          var idContainer;
          if (hasIdContainerProperty) {
            idContainer = this.__parseIdContainer();
          }
    
          if (propertyNameContainsIdContainer) {
            var containerAndDetail = this.__parseContainerDetailString(propertyName);
            propertyName = containerAndDetail.detail;
            idContainer = containerAndDetail.idContainer;
          }
    
          if (_.isUndefined(idContainer)) {
            idContainer = this.view;
          }
    
          return {
            idsPropertyName: propertyName,
            idContainer: idContainer
          };
        },
    
        /**
         * Parses the idContainer property of ids.
         * @returns {Object} the idContainer object to apply the properties value to (may not be the final idContainer depending on the property definition).
         * @private
         */
        __parseIdContainer: function() {
          var idContainerDefinition = this.ids.idContainer;
          var idContainer;
          if (_.isUndefined(idContainerDefinition)) {
            idContainer = undefined;
          } else if (_.isFunction(idContainerDefinition)) {
            var idContainerFxn = _.bind(idContainerDefinition, this);
            idContainer = idContainerFxn();
          } else if (_.isObject(idContainerDefinition)) {
            idContainer = idContainerDefinition;
          } else {
            throw new Error('Invalid idContainer.  Not an object or function: ' + JSON.stringify(this.ids));
          }
          return idContainer;
        },
    
        /**
         * Parses a string that contains a container and details for that container (event name or property name).
         * Strings are expected to be of the form '<some container path>:<detail>'.
         * @param fullContainerAndDetail
         * @returns {{detail: String, idContainer: *}} the idContainer is the actual object containing the ids.
         *         details is the rest of the idContainer string without the idContainer definition.
         * @private
         */
        __parseContainerDetailString: function(fullContainerAndDetail) {
          var containerString = '';
          if (containsContainerDefinition(fullContainerAndDetail)) {
            containerString = fullContainerAndDetail.split(CONTAINER_SEPARATOR, 1)[0];
          }
          var containerStringParts = containerString.split(PROPERTY_SEPARATOR);
    
          var rootContainerString = containerStringParts[0];
          var rootContainer;
          if (rootContainerString === 'this') {
            rootContainer = this;
          } else if (rootContainerString === 'behaviors' || rootContainerString === 'behavior') {
            var behaviorAlias = containerStringParts[1];
            rootContainerString += PROPERTY_SEPARATOR + behaviorAlias; // property track what part of the string has been consumed.
            rootContainer = this.view.getBehavior(behaviorAlias);
          } else {
            if (rootContainerString !== 'view') {
              rootContainerString = ''; // property track what part of the string has been consumed.
            }
            rootContainer = this.view;
          }
    
          var container = rootContainer;
          var rootContainerPropertyString = containerString.replace(rootContainerString, '');
          if (rootContainerPropertyString) {
  • ¶

    remove leading .

            if (rootContainerPropertyString[0] === PROPERTY_SEPARATOR) {
              rootContainerPropertyString = rootContainerPropertyString.slice(1);
            }
            container = getNestedProperty(rootContainer, rootContainerPropertyString);
          }
    
          var detail = fullContainerAndDetail.replace(containerString + CONTAINER_SEPARATOR, '');
          return {
            detail: detail,
            idContainer: container
          };
        },
    
        /**
         * Parse a string type update event.
         * Context Key (first part of the string up to the first ':') can be one of the following:
         *   this (maps to the behavior),
         *   view or empty (maps to the behavior's view),
         *   viewState (maps to the behavior's view's viewState),
         *   model (maps to the behavior's view's model),
         *   <*> any others are assumed to be the names of behaviors on this behavior's view.
         * @param {string} updateEventConfiguration a string representation of the event.
         * @returns {{eventName: String, idContainer: Backbone.Events}} the parsed configuration with the event name and idContainer object.
         * @private
         */
        __parseStringUpdateEvent: function(updateEventConfiguration) {
          var parsedIdContainerDetails = this.__parseContainerDetailString(updateEventConfiguration);
          if (parsedIdContainerDetails) {
            return {
              idContainer: parsedIdContainerDetails.idContainer,
              eventName: parsedIdContainerDetails.detail
            };
          }
        },
    
        /**
         * Rejects the promise chain if this behavior is already disposed.
         * @returns {external:jQuery-Deferred} that is resolved if the behavior is not disposed and rejects if the behavior is disposed.
         * @private
         */
        __abortIfDisposed: function() {
          var resultDeferred = $.Deferred();
          if (this.isDisposed()) {
            var rejectArguments = Array.prototype.slice.call(arguments);
            rejectArguments.push('Data Behavior disposed, aborting.');
            resultDeferred.reject.apply(resultDeferred, rejectArguments);
          } else {
            resultDeferred.resolve.apply(resultDeferred, arguments);
          }
          return resultDeferred.promise();
        },
    
        /**
         * Triggers a 'fetched' event with the payload { status: 'success' } when the fetch completes successfully.
         * @param {Object} response the response from the server.
         *   @param {boolean} [response.skipObjectRetrieval=false] if we retrieved objects, then trigger fetch event.
         *   @param {boolean} [response.forceFetchedEvent=false] if true then trigger fetch no matter what.
         * @returns {Object} response - returns the response to match promise spec that converts returned values into resolves.
         * @private
         */
        __fetchSuccess: function(response) {
          this.set('fetchSuccess', true);
          this.set('fetchedOnce', true);
          if (this.__shouldTriggerFetchedEvent(response)) {
            this.trigger('fetched', {
              status: FETCHED_STATUSES.SUCCESS,
              response: response
            });
            this.data.trigger('fetched', {
              status: FETCHED_STATUSES.SUCCESS,
              response: response
            });
          }
          this.trigger('fetched:ids');
          this.data.trigger('fetched:ids');
          return response;
        },
    
        /**
         * Triggers a 'fetched' event with the payload { status: 'failed' } when the fetch fails.
         * @param {Object} [response] the response from the server.
         *   @param {boolean} [response.skipObjectRetrieval=false] if we retrieved objects, then trigger fetch event.
         *   @param {boolean} [response.forceFetchedEvent=false] if true then trigger fetch no matter what.
         *   @param {boolean} [response.emptyIds=false] true if were are no ids retrieved.  False otherwise.
         * @throws {Object} response - throws an error to match promise spec that converts thrown errors into rejects.
         * @private
         */
        __fetchFailed: function(response) {
          this.set('fetchSuccess', false);
          this.set('fetchedOnce', true);
          if (this.__shouldTriggerFetchedEvent(response)) {
            this.trigger('fetched', {
              status: FETCHED_STATUSES.FAILURE,
              response: response
            });
            this.data.trigger('fetched', {
              status: FETCHED_STATUSES.FAILURE,
              response: response
            });
            if (response && response.emptyIds) {
              this.__firstEmptyFetchedTriggered = true;
            }
          }
          this.trigger('fetched:ids');
          this.data.trigger('fetched:ids');
  • ¶

    Deferred reject is more reliable than throwing an exception since it works in both jQuery 2 and 3.

          return new $.Deferred().reject(response).promise();
        },
    
        /**
         * Determines if the 'fetched' event should be triggered in the __fetchFailed or __fetchSuccess methods.
         * @param {Object} response to use to determine if the fetched event should be triggered.
         *   @param {boolean} [response.skipObjectRetrieval=false] if we retrieved objects, then trigger fetch event.
         *   @param {boolean} [response.forceFetchedEvent=false] if true then trigger fetch no matter what.
         * @returns {boolean} true if the fetched event should be triggered, false otherwise.
         * @private
         */
        __shouldTriggerFetchedEvent: function(response) {
          return !response || !response.skipObjectRetrieval || response.forceFetchedEvent;
        },
    
        /**
         * Skip retrieving objects if new ids list is empty and existing ids list is empty.
         * @param {(Array|Object)} idsResult
         * @returns {(Array|Object)} either the original idsResult
         *                        or { skipObjectRetrieval: true, forceFetchedEvent: true } if both the ids retrieved
         *                        and the current ids are empty.
         * @private
         */
        __skipRetrieveOnEmptyTrackedIdsAndNewIds: function(idsResult) {
          if (_.isEmpty(idsResult) && _.isEmpty(this.data.privateCollection.getTrackedIds())) {
            return { skipObjectRetrieval: true, forceFetchedEvent: true };
          } else {
            return idsResult;
          }
        },
    
        /**
         * Adds listeners when the view is activated.
         * @private
         */
        _activate: function() {
          this.listenToIdsPropertyChangeEvent();
          this._delegateUpdateEvents();
          this.data.activate();
        },
    
        /**
         * Stops listening when the view is deactivated.
         * @private
         */
        _deactivate: function() {
          this.stopListeningToIdsPropertyChangeEvent();
          this._undelegateUpdateEvents();
          this.data.deactivate();
        }
      });
    
      /**
       * @module Torso.behaviors.DataBehavior
       * @namespace Torso.behaviors.DataBehavior
       */
    
      /**
       * Data object used to create the .data property of the DataBehavior.
       * @class Data
       * @inner
       * @memberof DataBehavior
       */
      var Data = function(options) {
        this.initialize(options);
      };
    
      _.extend(Data.prototype, Events, /** @lends Data */ {
        /**
         * Instantiates the data objects and binds it to this behavior instance.
         * @param {Object} options to pass to the initialize methods.
         *   @param {DataBehavior} options.parentBehavior the data behavior instance that this Data object should be bound to.
         *   @param {Collection} options.privateCollection the private collection that this data represents.
         */
        initialize: function(options) {
          /**
           * The dataBehavior instance that owns this data object.
           * @property {DataBehavior} parentBehavior
           */
          this.parentBehavior = options.parentBehavior;
          /**
           * The private collection that this data object manages.
           * @property {Collection} privateCollection
           */
          this.privateCollection = options.privateCollection;
    
          _.bindAll(this, 'dispose');
        },
    
        /**
         * Determine if behavior is loading ids or objects.
         * @returns {boolean} true - the behavior is loading objects or ids.
         *                   false - the behavior is not loading objects or ids.
         */
        isLoading: function() {
          return this.parentBehavior.isLoading();
        },
    
        /**
         * Determine if the behavior is loading ids.
         * @returns {boolean} true - the behavior is currently loading ids.
         *                   false - the behavior is not currently loading ids.
         */
        isLoadingIds: function() {
          return this.parentBehavior.isLoadingIds();
        },
    
        /**
         * Determine if the behavior is loading objects.
         * @returns {boolean} true - the behavior is currently loading objects.
         *                   false - the behavior is not currently loading objects.
         */
        isLoadingObjects: function() {
          return this.parentBehavior.isLoadingObjects();
        },
    
        /**
         * Get the full data object contents.  Either an array if returnSingleResult is false or a single object if it is true.
         * @returns {(Object|Object[])} containing the full contents of either the collection or model.
         */
        toJSON: function() {
          var privateCollection = this.privateCollection;
          if (!this.parentBehavior.returnSingleResult) {
            return privateCollection.toJSON();
          }
    
          if (privateCollection.length === 0) {
            return undefined;
          } else if (privateCollection.length === 1) {
            var singleResultModel = privateCollection.at(0);
            return singleResultModel.toJSON();
          } else {
            throw new Error('Multiple results found, but single result expected: ' + JSON.stringify(privateCollection.toJSON()));
          }
        },
    
        /**
         * Get the full data object contents (either an array of model attributes or a single model attribute based on the
         * value of propertyName) or the value of a specific property if a single result is expected.
         *
         * If returnSingleResult is true then this will return the given property from the model (if that model exists).
         * If returnSingleResult is false then this will return an array containing that property from all of the retrieved models.
         * @param {string} [propertyName] the property to get from the model(s).
         * @returns {(Object|Object[])} containing the full contents of either the collection or model.
         */
        get: function(propertyName) {
          var privateCollection = this.privateCollection;
          if (!this.parentBehavior.returnSingleResult) {
            if (_.isString(propertyName)) {
              return privateCollection.pluck(propertyName);
            } else {
              return privateCollection.toJSON();
            }
          }
    
          var singleResultModel = this.getModel();
          if (singleResultModel) {
            if (_.isString(propertyName)) {
              return singleResultModel.get(propertyName);
            }
            return singleResultModel.toJSON();
          }
        },
    
        /**
         * @param {(number|string)} modelId The id of the model to get from the collection.
         * @returns {Backbone.Model} either the model with the given id or the only model on this behavior (if model id is undefined).
         * @throws an error if there are more than 1 result or the configuration of the behavior specifies returnSingleResult === false.
         */
        getModel: function(modelId) {
          var privateCollection = this.privateCollection;
          if (!this.parentBehavior.returnSingleResult && _.isUndefined(modelId)) {
            throw new Error('data.getModel() of a DataBehavior is only valid if the behavior is set to returnSingleResult === true');
          }
    
          if (!_.isUndefined(modelId)) {
            return privateCollection.get(modelId);
          }
    
          if (privateCollection.length === 0) {
            return undefined;
          } else if (privateCollection.length === 1) {
            return privateCollection.at(0);
          } else {
            throw new Error('Multiple results found, but single result expected: ' + JSON.stringify(privateCollection.toJSON()));
          }
        },
    
        /**
         * @returns {Backbone.Model[]} new array containing all the models in the data's private collection.
         */
        getModels: function() {
          return this.privateCollection.models.slice(0);
        },
    
        /**
         * Adds the listeners to the private collection.
         */
        activate: function() {
          this.listenTo(this.privateCollection, 'all', this.trigger);
        },
    
        /**
         * Removes the listeners on the private collection.
         */
        deactivate: function() {
          this.stopListening(this.privateCollection, 'all', this.trigger);
        },
    
        /**
         * Dispose of the data events.
         */
        dispose: function() {
          this.off();
          this.stopListening();
          this.privateCollection.dispose();
        }
      });
    
      DataBehavior.prototype.Data = Data;
      DataBehavior.FETCHED_STATUSES = FETCHED_STATUSES;
    
      return DataBehavior;
    }));