Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

I would like a simple syntax for interacting with my data objects (Data Utils) #282

Closed
mandragorn opened this issue Aug 23, 2016 · 25 comments

Comments

@mandragorn
Copy link
Contributor

mandragorn commented Aug 23, 2016

Should it be a behavior or built-in to the View?

If behavior, one behavior per data object or one behavior per view?

@mandragorn
Copy link
Contributor Author

torso data utils software engineering vecna shared documents

@mandragorn
Copy link
Contributor Author

mandragorn commented Aug 23, 2016

From discussion on 8/22:

  • Use a behavior
  • some api changes
    • cache: cacheCollectionInstance
    • isCollection: true (or type: 'collection' / 'model')
  • Trigger change events on behavior and have dependencies be between behaviors
  • prepare of behaviors is nice.

@mandragorn
Copy link
Contributor Author

mandragorn commented Sep 28, 2016

The result's (collection or model) toJSON() is added to the view's prepare namespaced by the behavior's alias.

i.e. a behavior with the alias demographics that is model-typed will add the demographics model's .toJSON() value to the view's template context as:

viewTemplateContext = {
  ...
  demographics: { ... }
  ...
}

a behavior with the alias appointments that is collection-typed will add the appointment collection's .toJSON() value to the view's template context as:

viewTemplateContext = {
  ...
  appointments: [ { ... }, { ... }, ... ]
  ...
}

Options:

  • cache - the torso collection that is acting as a cache used to create the private collections. ignored if model is set.
  • model - a 'singleton' model that will be managed by this behavior.
  • type - one of TorsoDataBehavior.TYPES.MODEL or TorsoDataBehavior.TYPES.COLLECTION, defaults to TorsoDataBehavior.TYPES.COLLECTION if not set. Ignored if model is set.
  • fetchMode - one of TorsoDataBehavior.FETCH_MODES.PULL or TorsoDataBehavior.FETCH_MODES.FETCH. defaults to TorsoDataBehavior.FETCH_MODES.PULL if not set.
  • id or ids - duck-typed
    • function() - expected to return the ids (either array or single primitive) to track with the private collection or to the single id to set on the singleton model.
    • String[] or Number[] - the ids to use directly.
    • String or Number - the id to use directly.
  • idProperties - identifies the property to get the ids from (on another object).
    • view:propertyName - pulls from a viewState property of the containing view.
    • model:propertyName - pulls from the model of the containing view.
    • behaviorAlias:propertyName - pulls from another behavior with the given behavior alias.
    • { 'propertyName': { ... } } - pulls from the supplied object the given property name.
  • events - cause this behavior to re-calculate its ids and refetch them from the server if the given events are triggered (space separated).
    • view:change:propertyName - change events on the containing view's viewState.
    • view:event - arbitrary event triggered on the view.
    • model:change:propertyName - change events on the containing view's model.
    • model:event - arbitrary even triggered on the view's model.
    • behaviorAlias:change:propertyName - change events from another behavior
    • behaviorAlias:event - arbitrary event triggered by another behavior on this view.
    • on:event - arbitrary event triggered by this behavior.
    • { 'event': { ... } } - arbitrary 'event' triggered on the supplied object.
  • criteria - uses fetchIdsByCriteria function defined on the cache collection to retrieve the ids. Can be either a function that returns the input to fetchIdsByCriteria or the criteria object to pass directly into the fetchIdsByCriteria function.

example configuration:

TorsoView.extend({
  behaviors: {
    demographics: {
      behavior: TorsoDataBehavior,
      cache: require('./demographicsCacheCollection'),
      type: TorsoDataBehavior.TYPES.MODEL,
      fetchMode: TorsoDataBehavior.FETCH_MODES.PULL,
      id: function() {
        return: this.view._patientVecnaId
      }
    }
  }
}

Behaviors can be pre-configured so you just set the behavior to use along with any deviations from the defaults.

var DemographicsModelBehavior = TorsoDataBehavior.extend({
  cache: require('./demographicsCacheCollection'),
  type: TorsoDataBehavior.TYPES.MODEL,
  fetchMode: TorsoDataBehavior.FETCH_MODES.PULL,
  id: function() {
    return: this.view._patientVecnaId
  }
});

TorsoView.extend({
  behaviors: {
    spouceDemographics: {
      behavior: DemographicsModelBehavior,
      idProperties: 'view:patientVecnaId'
    }
  }
}

Dependencies between data behaviors:

var AppointmentModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentCacheCollection'),
  type: TorsoDataBehavior.TYPES.COLLECTION,
  fetchMode: TorsoDataBehavior.FETCH_MODES.FETCH,
  idProperties: 'view:appointmentExternalId'
});

var AppointmentTypeModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCacheCollection'),
  type: TorsoDataBehavior.TYPES.COLLECTION,
  fetchMode: TorsoDataBehavior.FETCH_MODES.PULL,
  idProperties: 'appointment:type'
});

TorsoView.extend({
  behaviors: {
    appointment: {
      behavior: AppointmentModelBehavior
    },
    appointmentType: {
      behavior: AppointmentTypeModelBehavior
    }
  }
}

@mandragorn
Copy link
Contributor Author

comparison of current API and new one to determine whether it is too verbose / boilerplate-ish:
Now:

TorsoView.extend({
  dataModels: [
    {
      alias: 'appointment',
      modelType: 'secureMessage',
      criteria: function() {
        var appointmentExternalId = this._appointmentExternalId;
        if (!appointmentExternalId) {
          return null;
        }
         return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              externalIds: [appointmentExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'insuranceEstimation',
      modelType: 'insuranceEstimation',
      id: function() {
        if (this.getFetched('appointment') && financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
          return this.getFetched('appointment').get('appointmentDTO.accountExternalId');
        }
        return null;
      },
      dependencies: ['view:financialConfigLoaded', 'appointment:appointmentDTO.accountExternalId']
    },
    {
      alias: 'insurancePolicies',
      collectionType: 'insurancePolicy',
      criteria: function() {
        return {
          appointmentExternalId: this._appointmentExternalId
        };
      }
    },
    {
      alias: 'appointmentSecureMessages',
      collectionType: 'secureMessage',
      dependencies: [ 'appointment:appointmentDTO.accountExternalId' ],
      criteria: function() {
        var accountExternalId = this.getFetchedProperty('appointment', 'appointmentDTO.accountExternalId');

        if (!accountExternalId) {
          return null;
        }

        return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              accountNumbers: [accountExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'appointmentTypes',
      collectionType: 'appointmentType',
      idProperty: 'appointmentSecureMessages:appointmentDTO.externalAppointment.type',
      usePull: true
    }
  ]
});

New:

TorsoView.extend({
  behaviors: {
    financialConfig: {
      behavior: FinancialConfigBehavior
    }
    appointment: {
      behavior: AppointmentSecureMessageBehavior
    },
    insuranceEstimation: {
      behavior: InsuranceEstimationForAppointmentBehavior
    },
    insurancePolicies: {
      behavior: InsurancePoliciesForAppointmentBehavior
    },
    appointmentSecureMessages: {
      behavior: AppointmentSecureMessagesForAppointmentAccountBehavior
    },
    appointmentSecureMessagesTypes: {
      behavior: AppointmentMessagesAppointmentTypesBehavior
    }
  }
});
var FinancialConfigBehavior = TorsoDataBehavior.extend({
  model: require('./financialConfigModel')
});
var AppointmentSecureMessageBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  type: TorsoDataBehavior.TYPES.MODEL,
  events: 'view:change:appointmentExternalId',
  criteria: function() {
    var appointmentExternalId = this.view.get('appointmentExternalId');
    if (!appointmentExternalId) {
      return null;
    }

    return {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          externalIds: [appointmentExternalId]
        }
      }
    };
  }
});
var InsuranceEstimationForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insuranceEstimationCollection'),
  type: TorsoDataBehavior.TYPES.MODEL,
  events: ['financialConfig:change:normal.estimatedCostEnabled', 'financialConfig:change:normal.enabled', 'financialConfig:change:enabled', 'appointment:change:appointmentDTO.accountExternalId'],
  id: function() {
    if (financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
      return this.getBehavior('appointment').get('appointmentDTO.accountExternalId');
    }
    return null;
  }
});
var InsurancePoliciesForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insurancePolicyCollection'),
  idProperty: 'view:appointmentExternalId'
});
var AppointmentSecureMessagesForAppointmentAccountBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  events: 'appointment:change:appointmentDTO.accountExternalId',
  criteria: function(accountExternalId) {
    // alternatively ditch the function parameter defined by the events and use:
    //   var accountExternalId = this.view.getBehavior('appointment').get('appointmentDTO.accountExternalId');

    if (!accountExternalId) {
      return null;
    }

    return {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          accountNumbers: [accountExternalId]
        }
      }
    };
  }
});
var AppointmentMessagesAppointmentTypesBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCollection'),
  idProperty: 'appointmentSecureMessages:appointmentDTO.externalAppointment.type'
});

@mandragorn
Copy link
Contributor Author

is it worth trying to collapse id(s)/idProperty/criteria into a single configuration property?

@mandragorn
Copy link
Contributor Author

accessing properties:

  • to read a single property - view.getBehavior(alias).get(propertyName).
  • to access collection - view.getBehavior(alias).getPrivateCollection()
  • to get all properties of collection or view - view.getBehavior(alias).toJSON()
  • added to prepare of view by default under the alias of the behavior.
  • to listen for change (?) - view.listenTo(view.getBehavior(alias), 'change:propertyName', function() { ... });
    • all model or collection events are propagated to the behavior and are triggered appropriately when the model changes from one model to another (i.e. the id to fetch changes).
  • to get model object - view.getBehavior(alias).getModel() - returns null by default if it hasn't been fetched yet - can access model properties or model functions from here.

@kentmw
Copy link
Contributor

kentmw commented Sep 28, 2016

Awesome. Let's talk next Monday about this
On Tue, Sep 27, 2016 at 11:52 PM Josh Young notifications@github.com
wrote:

accessing properties:

  • to read a single property -
    view.getBehavior(alias).get(propertyName).
  • to access collection - view.getBehavior(alias).getPrivateCollection()
  • to get all properties of collection or view -
    view.getBehavior(alias).toJSON()
  • added to prepare of view by default under the alias of the behavior.
  • to listen for change (?) - view.listenTo(view.getBehavior(alias),
    'change:propertyName', function() { ... });
    • all model or collection events are propagated to the behavior and
      are triggered appropriately when the model changes from one model to
      another (i.e. the id to fetch changes).
  • to get model object - view.getBehavior(alias).getModel() - returns
    null by default if it hasn't been fetched yet - can access model properties
    or model functions from here.


You are receiving this because you are subscribed to this thread.
Reply to this email directly, view it on GitHub
#282 (comment),
or mute the thread
https://github.com/notifications/unsubscribe-auth/AAaBnOph5GrvcxDcjlZHRpXc1hiqkwDcks5queRwgaJpZM4JqdR3
.

@mandragorn
Copy link
Contributor Author

@kentmw sounds good.

@mandragorn
Copy link
Contributor Author

Notes from Cat:

General impression, the new looks like a similar amount of "stuff" but strikes me as being better organized and more readable. It also makes it look easier to share some of the behaviors across different views (I guess that's the point of behaviors...) but that also seems like a win here.

The way cache, model, and type interact/default seems awkward or confusing to me, but that might be my lack of familiarity with JS conventions and it's actually perfectly normal.

Reading the 'new' spec the only part that really looks boilerplate to me is the structure of specifying each behavior. With nothing being defined sort of "inline" the structure seems overly verbose, but that's probably not a big deal.

@mandragorn
Copy link
Contributor Author

for cache, model and type - it might be worth having 2 behaviors instead of a switch for one behavior. The goal is to specify these things separately:

  • cache vs singleton model - i.e. are we interacting with an object that will only ever have one (like a feature configuration) or something that we need a collection (cache) to manage.
    • Examples:
      • cache - AppointmentMessagesAppointmentTypesBehavior (from above)
      • model - FinancialConfigBehavior (from above)
  • type - model vs collection - are we expecting one or many ids (only relevant for caches).
    • Examples:
      • singleton model/model - FinancialConfigBehavior
      • cache/model - InsuranceEstimationForAppointmentBehavior
      • cache/collection - AppointmentMessagesAppointmentTypesBehavior

Options

  • could duck-type cache (I had that originally, but removed for clarity). If its a TorsoCollection use it like a cache of multiple items. If its a Backbone.Model then use it like a singleton model.
  • Have 2 behaviors - TorsoCacheDataBehavior and TorsoSingletonModelDataBehavior that share a common TorsoDataBehavior parent.

as for the type I could make those boolean fields (isCollectionResult, isModelResult) but I thought the enum style was more readable. You could just use type: 'collection' or type: 'model', the enums just attempt to reduce spelling issues.

@catbieber
Copy link
Contributor

The two behaviors approach sounds cleaner to me and would clear up the interactions that seemed weird to me with those fields.

@mandragorn
Copy link
Contributor Author

Notes from call on 10/3:

  • Update behavior api to be able to specify the behavior directly without the object wrapper.

Now:

TorsoView.extend({
  behaviors: {
    financialConfig: {
      behavior: FinancialConfigBehavior
    }
  }
});

Updated:

TorsoView.extend({
  behaviors: {
    financialConfig: FinancialConfigBehavior
  }
});
  • idProperites is a bad name - merge it into id into another duck-type of object ( { from: 'view', property: 'loaded' })
  • example of merging ids & criteria functions (have ids function return value of a deferred that resolves to the ids)
    • create example
  • drop singleton model type - can use events on that object
  • change type to resultType or change it to oneOrManyResults (?) or use isSingleResult (boolean) or returnsOne: true.
  • change events to updateEvents

Summary:

  • Update general behavior API to simplify when there is no additional configuration.
  • merge idProperties, id(s) and criteria into a single property duck-typed:
    • String or Number - the id
    • String[] or Number[] - the ids
    • Object identifying the from object and property of that object to get the ids from. Will automatically add a listener to that object for a change event with the given property name: { from: <Object or String>, property: <String or function(?)> }
    • function() - returns ids as either the actual ids or a deferred (deferred supports old criteria property - give example).
  • Remove singleton models as a thing.
  • Change type to something like isSingleResult: or returnsOne:

@mandragorn
Copy link
Contributor Author

mandragorn commented Oct 11, 2016

Updated API:

Options:

  • cache {Torso.Collection} - the torso collection that is acting as a cache used to create the private collections.

  • [returnSingleResult=false] {Boolean} true - a single model result is expected, false - a collection result is expected.

  • [alwaysFetch=false] {Boolean} true - if it should use fetch() instead of pull() on the private collection. false if it should use pull() instead. 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.

  • id or ids {String | Number | String[] | Number[] | Object | Function} - duck-typed property that identifies the ids to use.

    • String or Number - the id to use directly (equivalent to an array of a single id).

    • String[] or 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 needs to fire the change event for the given property and have a .get('propertyName') method. 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 will never change.

      • property {String} - the name of the property that defines the ids. The root object is assumed to be the view unless context is defined. The context is the object that fires a change event for the given property name. Uses the view or the context 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:' listener on it. If it is a string/number or array of string/number, then it will use that as the ids
      • context {Torso.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 refetch when the id property value changes.
      • Examples:
        • { property: '_patientId' }
        • { property: 'viewState.appointmentId' }
        • { property: 'model.type' }
        • { property: 'behaviors.demographics.appointments' }
        • { property: 'id', context: userService }
        • { property: 'username', context: 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 critera = { ... some criteria ... };
        return cache.fetchIdsByCriteria(criteria);
      }
      
  • updateEvents {String | Object | Array} - 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 behavior on this view (eventName can be a change:propertyName event).
    • { 'event': < object (or function returning an object) that the event is triggered on > } - arbitrary 'event' triggered on the supplied object.

example configuration:

TorsoView.extend({
  behaviors: {
    demographics: {
      behavior: TorsoDataBehavior,
      cache: require('./demographicsCacheCollection'),
      returnSingleResult: true,
      id: { property: '_patientVecnaId' }
    }
  }
}

Behaviors can be pre-configured so you just set the behavior to use along with any deviations from the defaults.

var DemographicsModelBehavior = TorsoDataBehavior.extend({
  cache: require('./demographicsCacheCollection'),
  returnSingleResult: true,
  id: { property: '_patientVecnaId' }
});

TorsoView.extend({
  behaviors: {
    spouceDemographics: {
      behavior: DemographicsModelBehavior,
      id: { property:  'viewState.patientVecnaId' }
    }
  }
}

Dependencies between data behaviors:

var AppointmentModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentCacheCollection'),
  id: { property: 'viewState.appointmentExternalId' }
});

var AppointmentTypeModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCacheCollection'),
  alwaysFetch: true,
  id: { property : 'behaviors.appointment.type' }
});

TorsoView.extend({
  behaviors: {
    appointment: {
      behavior: AppointmentModelBehavior
    },
    appointmentType: {
      behavior: AppointmentTypeModelBehavior
    }
  }
}

Existing API vs new API

comparison of current API and new one to determine whether it is too verbose / boilerplate-ish:

Now:

TorsoView.extend({
  dataModels: [
    {
      alias: 'appointment',
      modelType: 'secureMessage',
      criteria: function() {
        var appointmentExternalId = this._appointmentExternalId;
        if (!appointmentExternalId) {
          return null;
        }
         return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              externalIds: [appointmentExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'insuranceEstimation',
      modelType: 'insuranceEstimation',
      id: function() {
        if (this.getFetched('appointment') && financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
          return this.getFetched('appointment').get('appointmentDTO.accountExternalId');
        }
        return null;
      },
      dependencies: ['view:financialConfigLoaded', 'appointment:appointmentDTO.accountExternalId']
    },
    {
      alias: 'insurancePolicies',
      collectionType: 'insurancePolicy',
      criteria: function() {
        return {
          appointmentExternalId: this._appointmentExternalId
        };
      }
    },
    {
      alias: 'appointmentSecureMessages',
      collectionType: 'secureMessage',
      dependencies: [ 'appointment:appointmentDTO.accountExternalId' ],
      criteria: function() {
        var accountExternalId = this.getFetchedProperty('appointment', 'appointmentDTO.accountExternalId');

        if (!accountExternalId) {
          return null;
        }

        return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              accountNumbers: [accountExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'appointmentTypes',
      collectionType: 'appointmentType',
      idProperty: 'appointmentSecureMessages:appointmentDTO.externalAppointment.type',
      usePull: true
    }
  ]
});

New:

TorsoView.extend({
  behaviors: {
    appointment: AppointmentSecureMessageBehavior,
    insuranceEstimation: InsuranceEstimationForAppointmentBehavior,
    insurancePolicies: InsurancePoliciesForAppointmentBehavior,
    appointmentSecureMessages: AppointmentSecureMessagesForAppointmentAccountBehavior,
    appointmentSecureMessagesTypes: AppointmentMessagesAppointmentTypesBehavior
  }
});
var AppointmentSecureMessageBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  returnSingleResult: true,
  updateEvents: 'view:change:appointmentExternalId',
  ids: function(secureMessageCollectionCache) {
    var appointmentExternalId = this.view.get('appointmentExternalId');
    if (!appointmentExternalId) {
      return null;
    }

    var criteria = {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          externalIds: [appointmentExternalId]
        }
      }
    };
    return secureMessageCollectionCache.fetchIdsByCriteria(criteria);
  }
});
var financialFeatureConfigModel = require('./financialConfigModel');
var InsuranceEstimationForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insuranceEstimationCollection'),
  returnSingleResult: true,
  updateEvents: [{ 'change:normal.estimatedCostEnabled change:normal.enabled change:enabled': financialFeatureConfigModel }, 'appointment:change:appointmentDTO.accountExternalId'],
  id: function() {
    if (financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
      return this.getBehavior('appointment').get('appointmentDTO.accountExternalId');
    }
    return null;
  }
});
var InsurancePoliciesForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insurancePolicyCollection'),
  id: { property: 'view.appointmentExternalId' }
});
var AppointmentSecureMessagesForAppointmentAccountBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  updateEvents: 'appointment:change:appointmentDTO.accountExternalId',
  ids: function(secureMessageCollectionCache) {
    var accountExternalId = this.view.getBehavior('appointment').get('appointmentDTO.accountExternalId');

    if (!accountExternalId) {
      return null;
    }

    var criteria = {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          accountNumbers: [accountExternalId]
        }
      }
    };
    
    return secureMessageCollectionCache.fetchIdsByCriteria(criteria);
  }
});
var AppointmentMessagesAppointmentTypesBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCollection'),
  ids: { property: 'behaviors.appointmentSecureMessages.appointmentDTO.externalAppointment.type' }
});

@mikeobr
Copy link

mikeobr commented Dec 12, 2016

[returnSingleResult=false] {Boolean} true - a single model result is expected, false - a collection result is expected.

Is this redundant when the use of an id vs ids could tell you what is needed?

@mandragorn
Copy link
Contributor Author

mandragorn commented Dec 12, 2016

id and ids are aliases. returnSingleResult is what things are actually keyed to.

we could infer, but I wanted it to be more explicit

@mandragorn
Copy link
Contributor Author

mandragorn commented Dec 13, 2016

Updated API based on peer review comments:

Options:

  • cache {Torso.Collection} - the torso collection that is acting as a cache used to create the private collections.

  • [returnSingleResult=false] {Boolean} true - a single model result is expected, false - a collection result is expected.

  • [alwaysFetch=false] {Boolean} true - if it should use fetch() instead of pull() on the private collection. false if it should use pull() instead. 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.

  • id or ids {String | Number | String[] | Number[] | Object | Function} - duck-typed property that identifies the ids to use.

    • String or Number - the id to use directly (equivalent to an array of a single id).

    • String[] or 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 needs to fire the change event for the given property and have a .get('propertyName') method. 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 will never change.

      • property {String} - the name of the property that defines the ids. The root object is assumed to be the view unless context is defined. The context is the object that fires a change event for the given property name. Uses the view or the context 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:' listener on it. If it is a string/number or array of string/number, then it will use that as the ids
      • context {Torso.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 refetch when the id property value changes.
      • Examples:
        • { property: '_patientId' }
        • { property: 'viewState.appointmentId' }
        • { property: 'model.type' }
        • { property: 'behaviors.demographics.data.appointments' }
        • { property: 'id', context: userService }
        • { property: 'username', context: 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 critera = { ... some criteria ... };
        return cache.fetchIdsByCriteria(criteria);
      }
      
  • updateEvents {String | Object | Array} - 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).

    • [event name] below can be a change:propertyName event.
    • [property name] below is a direct property on the given object (does not use .get())
    • 'view:[event name]' - arbitrary event triggered on the view.
    • 'view.[property name]:[event name]' - arbitrary event triggered on the view's property.
    • 'viewState:[event name]' - arbitrary event triggered on the viewState.
    • 'model:[event name]' - arbitrary even triggered on the view's model.
    • 'model.[property name]:[event name]' - arbitrary even triggered on the view's model's property.
    • 'this:[event name]' - arbitrary event triggered by this behavior.
    • 'this.[property name]:[event name]' - arbitrary event triggered by this behavior's property.
    • 'this.data:[event name]' - arbitrary event triggered by this behavior's data property (specific example of this.[property name]:[event name]).
    • 'behaviorAlias:[event name]' - arbitrary event triggered by another behavior on this view.
    • 'behaviorAlias.[property name]:[event name]' - arbitrary event triggered by another behavior's property on this view.
    • 'behaviorAlias.data:[event name]' - arbitrary event triggered by another behavior's data property on this view (specific example of behaviorAlias.[property name]:[event name]).
    • { '[event name]': < object (or function returning an object) that the event is triggered on > } - arbitrary 'event' triggered on the supplied object.

example configuration:

TorsoView.extend({
  behaviors: {
    demographics: {
      behavior: TorsoDataBehavior,
      cache: require('./demographicsCacheCollection'),
      returnSingleResult: true,
      id: { property: '_patientVecnaId' }
    }
  }
}

Behaviors can be pre-configured so you just set the behavior to use along with any deviations from the defaults.

var DemographicsModelBehavior = TorsoDataBehavior.extend({
  cache: require('./demographicsCacheCollection'),
  returnSingleResult: true,
  id: { property: '_patientVecnaId' }
});

TorsoView.extend({
  behaviors: {
    spouceDemographics: {
      behavior: DemographicsModelBehavior,
      id: { property:  'viewState.patientVecnaId' }
    }
  }
}

Dependencies between data behaviors:

var AppointmentModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentCacheCollection'),
  id: { property: 'viewState.appointmentExternalId' }
});

var AppointmentTypeModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCacheCollection'),
  alwaysFetch: true,
  id: { property : 'behaviors.appointment.data.type' }
});

TorsoView.extend({
  behaviors: {
    appointment: {
      behavior: AppointmentModelBehavior
    },
    appointmentType: {
      behavior: AppointmentTypeModelBehavior
    }
  }
}

Existing API vs new API

comparison of current API and new one to determine whether it is too verbose / boilerplate-ish:

Now:

TorsoView.extend({
  dataModels: [
    {
      alias: 'appointment',
      modelType: 'secureMessage',
      criteria: function() {
        var appointmentExternalId = this._appointmentExternalId;
        if (!appointmentExternalId) {
          return null;
        }
         return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              externalIds: [appointmentExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'insuranceEstimation',
      modelType: 'insuranceEstimation',
      id: function() {
        if (this.getFetched('appointment') && financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
          return this.getFetched('appointment').get('appointmentDTO.accountExternalId');
        }
        return null;
      },
      dependencies: ['view:financialConfigLoaded', 'appointment:appointmentDTO.accountExternalId']
    },
    {
      alias: 'insurancePolicies',
      collectionType: 'insurancePolicy',
      criteria: function() {
        return {
          appointmentExternalId: this._appointmentExternalId
        };
      }
    },
    {
      alias: 'appointmentSecureMessages',
      collectionType: 'secureMessage',
      dependencies: [ 'appointment:appointmentDTO.accountExternalId' ],
      criteria: function() {
        var accountExternalId = this.getFetchedProperty('appointment', 'appointmentDTO.accountExternalId');

        if (!accountExternalId) {
          return null;
        }

        return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              accountNumbers: [accountExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'appointmentTypes',
      collectionType: 'appointmentType',
      idProperty: 'appointmentSecureMessages:appointmentDTO.externalAppointment.type',
      usePull: true
    }
  ]
});

New:

TorsoView.extend({
  behaviors: {
    appointment: AppointmentSecureMessageBehavior,
    insuranceEstimation: InsuranceEstimationForAppointmentBehavior,
    insurancePolicies: InsurancePoliciesForAppointmentBehavior,
    appointmentSecureMessages: AppointmentSecureMessagesForAppointmentAccountBehavior,
    appointmentSecureMessagesTypes: AppointmentMessagesAppointmentTypesBehavior
  }
});
var AppointmentSecureMessageBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  returnSingleResult: true,
  updateEvents: 'view:change:appointmentExternalId',
  ids: function(secureMessageCollectionCache) {
    var appointmentExternalId = this.view.get('appointmentExternalId');
    if (!appointmentExternalId) {
      return null;
    }

    var criteria = {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          externalIds: [appointmentExternalId]
        }
      }
    };
    return secureMessageCollectionCache.fetchIdsByCriteria(criteria);
  }
});
var financialFeatureConfigModel = require('./financialConfigModel');
var InsuranceEstimationForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insuranceEstimationCollection'),
  returnSingleResult: true,
  updateEvents: [{ 'change:normal.estimatedCostEnabled change:normal.enabled change:enabled': financialFeatureConfigModel }, 'appointment:change:appointmentDTO.accountExternalId'],
  id: function() {
    if (financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
      return this.getBehavior('appointment').data.get('appointmentDTO.accountExternalId');
    }
    return null;
  }
});
var InsurancePoliciesForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insurancePolicyCollection'),
  id: { property: 'view.appointmentExternalId' }
});
var AppointmentSecureMessagesForAppointmentAccountBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  updateEvents: 'appointment:change:appointmentDTO.accountExternalId',
  ids: function(secureMessageCollectionCache) {
    var accountExternalId = this.view.getBehavior('appointment').data.get('appointmentDTO.accountExternalId');

    if (!accountExternalId) {
      return null;
    }

    var criteria = {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          accountNumbers: [accountExternalId]
        }
      }
    };
    
    return secureMessageCollectionCache.fetchIdsByCriteria(criteria);
  }
});
var AppointmentMessagesAppointmentTypesBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCollection'),
  ids: { property: 'behaviors.appointmentSecureMessages.data.appointmentDTO.externalAppointment.type' }
});

@mandragorn
Copy link
Contributor Author

api update question... should we change { property: 'viewState.appointmentId' } to { property: 'viewState:appointmentId' } ? The colon might make it clearer where the idContainer ends and the id property definition starts. The more interesting change is behaviors.demographics.data.appointments -> behaviors.demographics.data:appointments now it becomes clearer that appointments is a collection of appointment ids on the demographics object. Instead of there being a data.appointments property on demographics that we are trying to access.

@mandragorn
Copy link
Contributor Author

that would also open us up to using any property of the view as an idContainer and still use the simple string syntax:

someOtherModel:visitIds

@kentmw
Copy link
Contributor

kentmw commented Dec 15, 2016 via email

@kentmw
Copy link
Contributor

kentmw commented Dec 15, 2016

Review of tonicdev:

"IdContainers can also hold the properties as fields on themselves. This is useful when the id values do not change over the lifetime of the idContainer. In this case nested properties are not supported."
this is for the use case when there is no event triggered?
might be useful to preface that
like: If your idContainer doesn't emit change events, you can still use the object if the properties don't change, etc.

"Configuring ids using a string description." should be made to look like title
i think they have ways to do that

"To use a property that is defined on the view as a field just specify the name of the property in the ids configuration:" make "on the view" bold
or just bold

"Note: Due to the duck-typing involved in allowing a single String id we have to use the additional construct of an object containing a field named 'property' for identifying a property using a simple string." add "instead of using the string directly" or some jazz

"Supported string identifiable idContainers are:

  • 'viewState'
  • 'model'
  • 'behaviors..data'" - the data parameter here is special. you can reference "behaviors." as well. ".data" is special for databehaviors

The long set of code examples after "Supported string identifiable idContainers..." should either have comments that title each example or broken into their own code blocks.

Consider adding titles to break up consistent chunks

@mandragorn
Copy link
Contributor Author

I can't figure out how to style stuff in the text - there is an open issue on the forums to add markdown, but the reply was effectively 'not done yet'

@mandragorn
Copy link
Contributor Author

Fang brought up a good question that might be worth revisiting.
Right now we have 2 modes that the output of the behavior works in - single result and collection result.
The only good reason that the .data object isn't just the private collection directly is that .toJSON and .get behave differently depending on returnSingleResult.

Would it be worth sacrificing the ability to programatically switch between returnSingleResult true/false (without a .setReturnSingleResult method) for .data to actually be the private collection (when returnSingleResult is false) or something closer to the model (when returnSingleResult is true)?

Another option that we talked about is to have 2 different behaviors. One for collection results and one for single results. That completely nukes being able to switch, but it does make the API more precise and allows .data for collection results to just be the private collection directly. And I'm not even sure it make sense to be able to switch between return single result true/false.

thoughts?

@mandragorn
Copy link
Contributor Author

mandragorn commented Dec 16, 2016

Updated API based on changing the separator between idContainer and property from '.' to ':':

Options:

  • cache {Torso.Collection} - the torso collection that is acting as a cache used to create the private collections.

  • [returnSingleResult=false] {Boolean} true - a single model result is expected, false - a collection result is expected.

  • [alwaysFetch=false] {Boolean} true - if it should use fetch() instead of pull() on the private collection. false if it should use pull() instead. 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.

  • id or ids {String | Number | String[] | Number[] | Object | Function} - duck-typed property that identifies the ids to use.

    • String or Number - the id to use directly (equivalent to an array of a single id).

    • String[] or 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 needs to fire the change event for the given property and have a .get('propertyName') method. 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 will never change.

      • 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:' listener on it. If it is a string/number or array of string/number, then it will use that as the ids
      • idContainer {Torso.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 refetch 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 critera = { ... some criteria ... };
        return cache.fetchIdsByCriteria(criteria);
      }
      
  • updateEvents {String | Object | Array} - 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).

    • [event name] below can be a change:propertyName event.
    • [property name] below is a direct property on the given object (does not use .get())
    • 'view:[event name]' - arbitrary event triggered on the view.
    • 'view.[property name]:[event name]' - arbitrary event triggered on the view's property.
    • 'viewState:[event name]' - arbitrary event triggered on the viewState.
    • 'model:[event name]' - arbitrary even triggered on the view's model.
    • 'model.[property name]:[event name]' - arbitrary even triggered on the view's model's property.
    • 'this:[event name]' - arbitrary event triggered by this behavior.
    • 'this.[property name]:[event name]' - arbitrary event triggered by this behavior's property.
    • 'this.data:[event name]' - arbitrary event triggered by this behavior's data property (specific example of this.[property name]:[event name]).
    • 'behaviors.behaviorAlias:[event name]' - arbitrary event triggered by another behavior on this view.
    • 'behaviors.behaviorAlias.[property name]:[event name]' - arbitrary event triggered by another behavior's property on this view.
    • 'behaviors.behaviorAlias.data:[event name]' - arbitrary event triggered by another behavior's data property on this view (specific example of behaviorAlias.[property name]:[event name]).
    • { '[event name]': < object (or function returning an object) that the event is triggered on > } - arbitrary 'event' triggered on the supplied object.

example configuration:

TorsoView.extend({
  behaviors: {
    demographics: {
      behavior: TorsoDataBehavior,
      cache: require('./demographicsCacheCollection'),
      returnSingleResult: true,
      id: { property: '_patientVecnaId' }
    }
  }
}

Behaviors can be pre-configured so you just set the behavior to use along with any deviations from the defaults.

var DemographicsModelBehavior = TorsoDataBehavior.extend({
  cache: require('./demographicsCacheCollection'),
  returnSingleResult: true,
  id: { property: '_patientVecnaId' }
});

TorsoView.extend({
  behaviors: {
    spouceDemographics: {
      behavior: DemographicsModelBehavior,
      id: { property:  'viewState:patientVecnaId' }
    }
  }
}

Dependencies between data behaviors:

var AppointmentModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentCacheCollection'),
  id: { property: 'viewState:appointmentExternalId' }
});

var AppointmentTypeModelBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCacheCollection'),
  alwaysFetch: true,
  id: { property : 'behaviors.appointment.data:type' }
});

TorsoView.extend({
  behaviors: {
    appointment: AppointmentModelBehavior,
    appointmentType: AppointmentTypeModelBehavior
  }
}

Existing API vs new API

comparison of current API and new one to determine whether it is too verbose / boilerplate-ish:

Now:

TorsoView.extend({
  dataModels: [
    {
      alias: 'appointment',
      modelType: 'secureMessage',
      criteria: function() {
        var appointmentExternalId = this._appointmentExternalId;
        if (!appointmentExternalId) {
          return null;
        }
         return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              externalIds: [appointmentExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'insuranceEstimation',
      modelType: 'insuranceEstimation',
      id: function() {
        if (this.getFetched('appointment') && financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
          return this.getFetched('appointment').get('appointmentDTO.accountExternalId');
        }
        return null;
      },
      dependencies: ['view:financialConfigLoaded', 'appointment:appointmentDTO.accountExternalId']
    },
    {
      alias: 'insurancePolicies',
      collectionType: 'insurancePolicy',
      criteria: function() {
        return {
          appointmentExternalId: this._appointmentExternalId
        };
      }
    },
    {
      alias: 'appointmentSecureMessages',
      collectionType: 'secureMessage',
      dependencies: [ 'appointment:appointmentDTO.accountExternalId' ],
      criteria: function() {
        var accountExternalId = this.getFetchedProperty('appointment', 'appointmentDTO.accountExternalId');

        if (!accountExternalId) {
          return null;
        }

        return {
          isPatient: true,
          appointmentCriteria: {
            externalAppointmentCriteria: {
              accountNumbers: [accountExternalId]
            }
          }
        };
      }
    },
    {
      alias: 'appointmentTypes',
      collectionType: 'appointmentType',
      idProperty: 'appointmentSecureMessages:appointmentDTO.externalAppointment.type',
      usePull: true
    }
  ]
});

New:

TorsoView.extend({
  behaviors: {
    appointment: AppointmentSecureMessageBehavior,
    insuranceEstimation: InsuranceEstimationForAppointmentBehavior,
    insurancePolicies: InsurancePoliciesForAppointmentBehavior,
    appointmentSecureMessages: AppointmentSecureMessagesForAppointmentAccountBehavior,
    appointmentSecureMessagesTypes: AppointmentMessagesAppointmentTypesBehavior
  }
});
var AppointmentSecureMessageBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  returnSingleResult: true,
  updateEvents: 'view:change:appointmentExternalId',
  ids: function(secureMessageCollectionCache) {
    var appointmentExternalId = this.view.get('appointmentExternalId');
    if (!appointmentExternalId) {
      return null;
    }

    var criteria = {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          externalIds: [appointmentExternalId]
        }
      }
    };
    return secureMessageCollectionCache.fetchIdsByCriteria(criteria);
  }
});
var financialFeatureConfigModel = require('./financialConfigModel');
var InsuranceEstimationForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insuranceEstimationCollection'),
  returnSingleResult: true,
  updateEvents: [{ 'change:normal.estimatedCostEnabled change:normal.enabled change:enabled': financialFeatureConfigModel }, 'appointment:change:appointmentDTO.accountExternalId'],
  id: function() {
    if (financialFeatureConfigModel.isEstimatedCostEnabled(financialFeatureConfigModel.CONFIG_LEVEL.NORMAL)) {
      return this.getBehavior('appointment').data.get('appointmentDTO.accountExternalId');
    }
    return null;
  }
});
var InsurancePoliciesForAppointmentBehavior = TorsoDataBehavior.extend({
  cache: require('./insurancePolicyCollection'),
  id: { property: 'appointmentExternalId' }
});
var AppointmentSecureMessagesForAppointmentAccountBehavior = TorsoDataBehavior.extend({
  cache: require('./secureMessageCollection'),
  updateEvents: 'appointment:change:appointmentDTO.accountExternalId',
  ids: function(secureMessageCollectionCache) {
    var accountExternalId = this.view.getBehavior('appointment').data.get('appointmentDTO.accountExternalId');

    if (!accountExternalId) {
      return null;
    }

    var criteria = {
      isPatient: true,
      appointmentCriteria: {
        externalAppointmentCriteria: {
          accountNumbers: [accountExternalId]
        }
      }
    };
    
    return secureMessageCollectionCache.fetchIdsByCriteria(criteria);
  }
});
var AppointmentMessagesAppointmentTypesBehavior = TorsoDataBehavior.extend({
  cache: require('./appointmentTypeCollection'),
  ids: { property: 'behaviors.appointmentSecureMessages.data:appointmentDTO.externalAppointment.type' }
});

@mandragorn
Copy link
Contributor Author

docs are up at https://runkit.com/torso/databehavior/0.0.5 - please review if you have time.

@mandragorn
Copy link
Contributor Author

Comments on 0.0.7 of docs.

  • Move to .md in torso.

  • ' -> "

  • '' -> no string, empty string.. etc.

  • [? -> ids] ==> get ids
    ** Also summary after heading

  • Cell-like structure:
    ** If your idContainer doesn't emit change events, you can still use the object if the properties don't change by referencing a field on the object directly. This is useful when the id values do not change over the lifetime of the idContainer. In this case nested properties are not supported.
    ** separate .get & .property.

  • Note: Due to the duck-typing -> give example

  • Enumerate string identifiable containers.

** 'viewState' -> idContainer -> viewState

  • swap returnSingleResult == true & == false

  • idsContainer.trigger( -> add purpose of why this is used.

  • When direct dependence on id properties that fire their own change events are not enough - give example.
    ** Ids are generally one to one. When you need to calculate the ids based on multiple inputs then updateEvents are used and the ids calculation can be done .

  • Simplify (Update events follow the same rules for identifying containers (or event emitters) as id properties do for idContainers.)
    ** Event emmiters are behavior level, idContainers are view level. Make this explanation better.

{
'change:enabled': magazineConfiguration,
'fetched': someOtherEventEmitter
},
{
'change:enabled': applicationConfiguration,
'some:random:event': yetAnotherEventEmitter
}

  • just 'change:enabled'

mandragorn pushed a commit to mandragorn/backbone-torso that referenced this issue Jan 30, 2017
mandragorn pushed a commit to mandragorn/backbone-torso that referenced this issue Jan 30, 2017
mandragorn pushed a commit to mandragorn/backbone-torso that referenced this issue Jan 30, 2017
mandragorn pushed a commit to mandragorn/backbone-torso that referenced this issue Jan 31, 2017
mandragorn pushed a commit to mandragorn/backbone-torso that referenced this issue Jan 31, 2017
mandragorn pushed a commit to mandragorn/backbone-torso that referenced this issue Jan 31, 2017
mandragorn added a commit that referenced this issue Jan 31, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants