Giter Club home page Giter Club logo

ampersand-state's Introduction

ampersand-state

Lead Maintainer: Philip Roberts

Coverage Status

Part of the Ampersand.js toolkit for building clientside applications.

An observable, extensible state object with derived watchable properties.

Ampersand-state serves as a base object for ampersand-model but is useful any time you want to track complex state.

ampersand-model extends ampersand-state to include assumptions that you'd want if you're using models to model data from a REST API. But by itself ampersand-state is useful for anytime you want something to model state, that fires events for changes and lets you define and listen to derived properties.

For further explanation see the learn ampersand-state guide.

Install

npm install ampersand-state --save

API Reference

extend AmpersandState.extend({ })

To create a State class of your own, you extend AmpersandState and provide instance properties and options for your class. Typically, this is when you'll define the properties (props, session, and derived) of your state class, and any instance methods to be attached to instances of your class.

extend correctly sets up the prototype chain, so that subclasses created with extend can be further extended as many times as you like.

Definitions like props, session, derived, etc. will be merged with superclass definitions.

var Person = AmpersandState.extend({
    props: {
        firstName: 'string',
        lastName: 'string'
    },
    session: {
        signedIn: ['boolean', true, false],
    },
    derived: {
        fullName: {
            deps: ['firstName', 'lastName'],
            fn: function () {
                return this.firstName + ' ' + this.lastName;
            }
        }
    }
});

AmpersandState.extend does more than just copy attributes from one prototype to another. As such, it is incompatible with CoffeeScript's class-based extend. TypeScript users may have similar issues.

For instance, this will not work (since it never actually calls AmpersandState.extend):

// don't do this!
class Foo extends AmpersandView
     constructor: (options)->
         @special = options.special
         super

constructor/initialize new AmpersandState([attrs], [options])

When creating an instance of a state object, you can pass in the initial values of the attributes which will be set on the state. Unless extraProperties is set to allow, you will need to have defined these attributes in props or session.

If you have defined an initialize function for your subclass of State, it will be invoked at creation time.

var me = new Person({
    firstName: 'Phil',
    lastName: 'Roberts'
});

me.firstName //=> Phil

Available options:

  • [parse] {Boolean} - whether to call the class's parse function with the initial attributes. Defaults to false.
  • [parent] {AmpersandState} - pass a reference to a state's parent to store on the state.

idAttribute state.idAttribute

The attribute that should be used as the unique id of the state. getId uses this to determine the id for use when constructing a model's url for saving to the server.

Defaults to 'id'.

var Person = AmpersandModel.extend({
    idAttribute: 'personId',
    urlRoot: '/people',
    props: {
        personId: 'number',
        name: 'string'
    }
});

var me = new Person({ personId: 123 });

console.log(me.url()) //=> "/people/123"

getId state.getId()

Gets the state's ID, per idAttribute configuration. Should always be how ID is determined by other code.

namespaceAttribute state.namespaceAttribute

The property name that should be used as a namespace. Namespaces are completely optional, but exist in case you need to make an additional distinction between states, that may be of the same type, with potentially conflicting IDs but are in fact different.

Defaults to 'namespace'.

getNamespace state.getNamespace()

Get namespace of state per namespaceAttribute configuration. Should always be how namespace is determined by other code.

typeAttribute

The property name that should be used to specify what type of state this is. This is optional, but specifying a state type types provides a standard, yet configurable way to determine what type of state it is.

Defaults to 'modelType'.

getType state.getType()

Get type of state per typeAttribute configuration. Should always be how type is determined by other code.

extraProperties AmpersandState.extend({ extraProperties: 'allow' })

Determines how properties that aren't defined in props, session or derived are handled. May be set to 'allow', 'ignore', or 'reject'.

Defaults to 'ignore'.

var StateA = AmpersandState.extend({
    extraProperties: 'allow',
});

var stateA = new StateA({ foo: 'bar' });
stateA.foo === 'bar' //=> true


var StateB = AmpersandState.extend({
    extraProperties: 'ignore',
});

var stateB = new StateB({ foo: 'bar' });
stateB.foo === undefined //=> true


var stateC = AmpersandState.extend({
    extraProperties: 'reject'
});

var stateC = new StateC({ foo: 'bar' })
//=> TypeError('No foo property defined on this state and extraProperties not set to "ignore" or "allow".');

collection state.collection

A reference to the collection a state is in, if in a collection.

This is used for building the default url property, etc.

Which is why you can do this:

// some ampersand-rest-collection instance
// with a `url` property
widgets.url //=> '/api/widgets'

// get a widget from our collection
var badWidget = widgets.get('47');

// Without a `collection` reference, this
// widget wouldn't know what URL to build
// when calling destroy
badWidget.destroy(); // does a DELETE /api/widgets/47

cid state.cid

A special property of states, the cid (or client id) is a unique identifier automatically assigned to all states when they are first created. Client IDs are handy when the state has not been saved to the server, and so does not yet have its true id, but still needs a unique id (for rendering in the UI, and so on).

var userA = new User();
console.log(userA.cid) //=> "state-1"

var userB = new User();
console.log(userB.cid) //=> "state-2"

isNew state.isNew()

Has this state been saved to the server yet? If the state does not yet have an id (using getId()), it is considered to be new.

escape state.escape()

Similar to get, but returns the HTML-escaped version of a state's attribute. If you're interpolating data from the state into HTML, use escape when retrieving attributes to help prevent XSS attacks.

var hacker = new PersonModel({
    name: "<script>alert('xss')</script>"
});

document.body.innerHTML = hacker.escape('name');

isValid state.isValid()

Check if the state is currently valid. It does this by calling the state's validate method (if you've provided one).

dataTypes AmpersandState.extend({ datatypes: myCustomTypes })

ampersand-state defines several built-in datatypes:

  • string
  • number
  • boolean
  • array
  • object
  • date
  • state
  • any

Of these, object, array, and any allow for a lot of extra flexibility.

However, sometimes it's useful to define your own custom datatypes. Doing so allows you to use them in the props below, along with all their features (like required, default, etc).

Setting type is required. A typeError will be thrown if it's missing or has not been chosen (either from default types or your custom ones).

To define a type, you generally will provide an object with 4 member functions (though only 2 are usually necessary) get, set, default, and compare.

FUNCTION RETURNS DESCRIPTION
set : function(newVal){} {type : type, val : newVal} Called on every set. Should return an object with two members: val and type. If the type value does not equal the name of the dataType you defined, a TypeError should be thrown.
compare : function(currentVal, newVal, attributeName){} boolean Called on every set. Should return true if oldVal and newVal are equal. Non-equal values will eventually trigger change events, unless the state's set (not the dataTypes's!) is called with the option {silent : true}.
onChange : function (value, previousValue, attributeName){} Called after the value changes. Useful for automatically setting up or tearing down listeners on properties.
get : function(val){} val Overrides the default getter of this type. Useful if you want to make defensive copies. For example, the date dataType returns a clone of the internally saved date to keep the internal state consistent.
default : function(){} val Returns the default value for this type.

For example, let's say your application uses a special type of date: JulianDate. You'd like to define this as a type in state, but don't want to just use any or object as the type.

To define it:

// Julian Date is a 'class' defined elsewhere:
// it has an 'equals' method and takes `{julianDays : number}` as a constructor

var Person = AmpersandState.extend({
   dataTypes : {
        julianDate : {
           // set called every time someone tried to set a property of this datatype
           set : function(newVal){
               if(newVal instanceof JulianDate){
                   return {
                       val : newVal,
                       type : 'julianDate'
                   };
               }
               try{
                   // try to parse it from passed in value:
                   var newDate = new JulianDate(newVal);

                   return {
                       val : newDate,
                       type : 'julianDate'
                   };
               }catch(parseError){
                   // return the value with what we think its type is
                   return {
                       val : newVal,
                       type : typeof newVal
                   };
               }
           },
           compare : function(currentVal, newVal, attributeName){
               return currentVal.equals(newVal);
           }
       }

   }
   props : {
       bornOn : 'julianDate',
       retiresOn : {
           type : 'julianDate',
           required : 'true',
           default : function(){
                  // assuming an 'add' function on julian date which returns a new JulianDate
                  return this.bornOn.add('60','years');
               }
           }
   }
});

var person = new Person({ bornOn : new JulianDate({julianDays : 1000}); }
// this will also work and will build a new JulianDate
var person = new Person({bornOn : {julianDays : 1000}});

// will construct a new julian date for us
// and will also trigger a change event
person.bornOn = {julianDays : 1001};

// but this will not trigger a change event since the equals method would return true
person.bornOn = {julianDays : 1001};

props AmpersandState.extend({ props: { name: 'string' } })

The props object describes the observable properties of your state class.

Always pass props to extend! Never set it on an instance, as it won't define new properties.

Properties can be defined in three different ways:

  1. As a string with the expected dataType
  • One of string, number, boolean, array, object, date, or any. (Example: name: 'string'.)
  • Can also be set to the name of a custom dataTypes, if the class defines any.
  1. An array of [dataType, required, default]
  2. An object { type: 'string', required: true, default: '' , values: [], allowNull: false, setOnce: false }
  • default: the value that the property will be set to if it is undefined (either by not being set during initialization, or by being explicit set to undefined).
  • If required is true:
    • If the property has a default, it will start with that value, and revert to it after a call to unset(propertyName)
    • Otherwise, calls to unset(propertyName) throw an error
  • If values array is passed, then you'll be able to change the property to one of those values only.
  • If setOnce is true, then you'll be able to set property only once.
    • If the property has a default, and you don't set the value initially, the property will be permanently set to the default value.
    • Otherwise, if you don't set the value initially, it can be set later, but only once.
  • If test function is passed, then a negative validation test will be executed every time this property is about to be set.
    • If the validation passes, the function must return false to tell State to go ahead and set the value.
    • Otherwise, it should return a string with the error message describing the validation failure. (In this case, State will throw a TypeError with "Property '<property>' failed validation with error: <errorMessage>".)

Trying to set a property to an invalid type will throw an error.

See get and set for more information about getting and setting properties.

var Person = AmpersandState.extend({
    props: {
        name: 'string',
        age: 'number',
        paying: ['boolean', true, false], // required attribute, defaulted to false
        type: {
            type: 'string',
            values: ['regular-hero', 'super-hero', 'mega-hero']
        },
        numberOfChildren: {
            type: 'number',
            test: function(value){
                if (value < 0) {
                    return "Must be a positive number";
                }
                return false;
            }
        },
    }
});

reserved prop, session names

The following should not be used as prop names for any state object. This of course includes things based on state such as ampersand-model and ampersand-view.

If you're consuming an API you don't control, you can rename keys by overwriting parse and serialize methods.

bind, changedAttributes, cid, clear, collection, constructor, createEmitter, escape, extraProperties, get, getAttributes, getId, getNamespace, getType, hasChanged, idAttribute, initialize, isNew, isValid, listenTo, listenToAndRun, listenToOnce, namespaceAttribute, off, on, once, parent, parse, previous, previousAttributes, serialize, set, stopListening, toJSON, toggle, trigger, typeAttribute, unbind, unset, url

defaulting to objects/arrays

You will get an error if you try to set the default of any property as either an object or array. This is because those two dataTypes are mutable and passed by reference. (Thus, if you did set a property's default to ['a','b'], it would return the same array on every new instantiation of the state.)

Instead, if you want a property to default to an array or an object, just set default to a function, like this:

AmpersandModel.extend({
    props: {
        checkpoints: {
            type: 'array',
            default: function () { return []; }
        }
    }
});

NOTE: Both array and object have this behavior built-in: they default to empty versions of themselves. You would only need to do this if you wanted to default to an array/object that wasn't empty.

session AmpersandState.extend({ session: { name: 'string' } })

Session properties are defined and work in exactly the same way as props, but generally only exist for the lifetime of the page. They would not typically be persisted to the server, and are not returned by calls to toJSON() or serialize().

var Person = AmpersandState.extend({
    props: {
        name: 'string',
    },
    session: {
        isLoggedIn: 'boolean'
    }
);

derived AmpersandState.extend({ derived: { derivedProperties }})

Derived properties (also known as computed properties) are properties of the state object that depend on other properties to determine their value. They may depend on properties defined in props, session, or even derived—as well as the same from state props or children.

Best demonstrated with an example:

var Address = AmpersandState.extend({
  props: {
    'street': 'string',
    'city': 'string',
    'region': 'string',
    'postcode': 'string'
  }
});

var Person = AmpersandState.extend({
    props: {
        firstName: 'string',
        lastName: 'string',
        address: 'state'
    },
    derived: {
        fullName: {
            deps: ['firstName', 'lastName'],
            fn: function () {
                return this.firstName + ' ' + this.lastName;
            }
        },
        mailingAddress: {
            deps: ['address.street', 'address.city', 'address.region', 'address.postcode'],
            fn: function () {
                var self = this;
                return ['street','city','region','postcode'].map(function (prop) {
                    var val = self.address[prop];
                    if (!val) return val;
                    return (prop === 'street' || prop === 'city') ? val + ',' : val;
                }).filter(function (val) {
                    return !!val;
                }).join(' ');
            }
        }
    }
});

var person = new Person({
    firstName: 'Phil',
    lastName: 'Roberts',
    address: new Address({
        street: '123 Main St',
        city: 'Anyplace',
        region: 'BC',
        postcode: 'V6A 2S5'
    })
});
console.log(person.fullName) //=> "Phil Roberts"
console.log(person.mailingAddress) //=> "123 Main St, Anyplace, BC V6A 2S5"

person.firstName = 'Bob';
person.address.street = '321 St. Charles Pl'
console.log(person.fullName) //=> "Bob Roberts"
console.log(person.mailingAddress) //=> "321 St. Charles Pl, Anyplace, BC V6A 2S5"

See working example at RequireBin

Each derived property is defined as an object with the following properties:

  • deps {Array} - An array of property names which the derived property depends on.
  • fn {Function} - A function which returns the value of the computed property. It is called in the context of the current object, so that this is set correctly.
  • cache {Boolean} - Whether to cache the property. Uncached properties are computed every time they are accessed. Useful if it depends on the current time for example. Defaults to true.

Derived properties are retrieved and fire change events just like any other property. However, they cannot be set directly. Caching ensures that the fn function is only run when any of the dependencies change, and change events are only fired if the result of calling fn() has actually changed.

children AmpersandState.extend({ children: { profile: Profile } })

Define child state objects to attach to the object. Attributes passed to the constructor or to set() will be proxied to the children/collection. Children's change events are proxied to the parent.

var AmpersandState = require('ampersand-state');
var Hat = AmpersandState.extend({
    props: {
        color: 'string'
    }
});

var Person = AmpersandState.extend({
    props: {
        name: 'string'
    },
    children: {
        hat: Hat
    }
});

var me = new Person({ name: 'Phil', hat: { color: 'red' } });

me.on('all', function (eventName) {
    console.log('Got event: ', eventName);
});

console.log(me.hat) //=> Hat{color: 'red'}

me.set({ hat: { color: 'green' } });
//-> "Got event: change:hat.color"
//-> "Got event: change"

console.log(me.hat) //=> Hat{color: 'green'}

NOTE: If you want to be able to swap out and get a change event from a child model, don't use children. Instead, define a prop in props of type state.

children and collections are not just a property of the parent; they're part of the parent. When you create the parent, an instance of any children or collections will be instantiated as part of instantiating the parent, whether they have any data or not.

Calling .set() on the parent with a nested object will automatically set() them on children and collections, too. This is super handy for APIs like this one that return nested JSON structures.

Also, there will be no change events triggered if you replace a child with something else after you've instantiated the parent, because it's not a true property in the props sense. If you need a prop that stores a state instance, define it as such—don't use children.

The distinction is important! Without it, the following would be problematic:

var Person = State.extend({
    props: {
        child: {
            type: 'state'
        }
    }
});

var person = new Person()

// throws type error because `{}` isn't a state object
person.child = {};
// should this work? What should happen if the `child` prop isn't defined yet?
person.set({child: {name: 'mary'}});

So, while having children in addition to props of type state may feel redundant, they both exist to help disambiguate how they're meant to be used.

collections AmpersandState.extend({ collections: { widgets: Widgets } })

Define child collection objects to attach to the object. Attributes passed to the constructor or to set() will be proxied to the collections.

NOTE: Currently, events don't automatically proxy from collections to parent. This is for efficiency reasons. But there are ongoing discussions about how to best handle this.

var State = require('ampersand-state');
var Collection = require('ampersand-collection');

var Widget = State.extend({
    props: {
        name: 'string',
        funLevel: 'number'
    }
});

var WidgetCollection = Collection.extend({
    model: Widget
});

var Person = State.extend({
    props: {
        name: 'string'
    },
    collections: {
        widgets: WidgetCollection
    }
});

var me = new Person({
    name: 'Henrik',
    widgets: [
        { name: 'rc car', funLevel: 8 },
        { name: 'skis', funLevel: 11 }
    ]
});

console.log(me.widgets.length); //=> 2
console.log(me.widgets instanceof WidgetCollection); //=> true

parse

parse is called when the state is initialized, allowing the attributes to be modified, remapped, renamed, etc., before they are actually applied to the state.

In ampersand-state, parse is only called when the state is initialized, and only if { parse: true } is passed to the constructor's options.

var Person = AmpersandState.extend({
    props: {
        id: 'number',
        name: 'string'
    },

    parse: function (attrs) {
        attrs.id = attrs.personID; //remap an oddly named attribute
        delete attrs.personID;

        return attrs;
    }
});

var me = new Person({ personID: 123, name: 'Phil' },{ parse: true});

console.log(me.id) //=> 123
console.log(me.personID) //=> undefined

(parse is arguably more useful in ampersand-model, where data typically comes from the server.)

serialize state.serialize([options])

Serialize the state object into a plain object, ready for sending to the server (typically called via toJSON).

By default, this returns only properties defined in props, omitting properties in session and derived. To also serialize session or derived attributes, you can pass in a options object. The options object should match that accepted by .getAttributes(...).

This method will also serialize any children or collections by calling their serialize methods.

get state.get(attribute); state[attribute]; state.firstName

Get the current value of an attribute from the state object. Attributes can be accessed directly, or a call to the Backbone style get.

// these are all equivalent
person.get('firstName');
person['firstName'];
person.firstName

Get will retrieve props, session, or derived properties all in the same way.

set state.set(attributes, [options]); state.firstName = 'Henrik';

Sets an attribute, or multiple attributes, on the state object. If any of the state object's attributes change, it will trigger a "change" event.

Change events for specific attributes are also triggered, which you can listen for as well. For example: "change:firstName" and "change:content". If the update affects any derived properties, their values will be updated and fire "change" events as well.

Attributes can be set directly, or via a call to the backbone style set (useful if you wish to update multiple attributes at once):

person.set({firstName: 'Phil', lastName: 'Roberts'});
person.set('firstName', 'Phil');
person.firstName = 'Phil';

Possible options (when using state.set()):

  • silent {Boolean} - prevents triggering of any change events as a result of the set operation.
  • unset {Boolean} - unset the attributes keyed in the attributes object instead of setting them.

NOTE: When passing an object as the attributes argument, only that object's own enumerable properties (i.e. those that can be accessed with Object.keys(object)) are read and set. This behaviour is new as of v5.0.0. (Prior version relied on for...in to access an object's properties, both owned by that object and those inherited through the prototypal chain.)

unset state.unset(attribute|attributes[], [options])

Clear the named attribute, or an array of named attributes, from the state object. Fires a "change" event and a "change:attributeName" event unless silent is passed as an option.

If the attribute being unset is required and has a default value as defined in either props or session, it will be set to that value, otherwise it will be undefined.

// unset a single attribute
person.unset('firstName')
// unset multiple attributes
person.unset(['firstName', 'lastName'])

clear state.clear([options])

Clear all the attributes from the state object, by calling the unset function for each attribute, with the options provided.

person.clear()

toggle state.toggle('a')

Shortcut to toggle boolean properties, or to cycle through array of specified property's values. (See the values option and example, below.)

When you reach the last available value from given array, toggle will go back to the beginning and use first one.

Fires "change" events, as you would expect from set().

var Person = AmpersandState.extend({
    props: {
        active: 'boolean',
        color: {
            type: 'string',
            values: ['red', 'green', 'blue']
        }
    }
});

var me = new Person({ active: true, color: 'green' });

me.toggle('active');
console.log(me.active) //=> false

me.toggle('color');
console.log(me.color) //=> 'blue'

me.toggle('color');
console.log(me.color) //=> 'red'

previousAttributes state.previousAttributes()

Return a copy of the object's previous attributes (the state before the last "change" event). Useful for getting a diff between versions of a state, or getting back to a valid state after an error occurs.

hasChanged state.hasChanged([attribute])

Determine if the state has been modified since the last "change" event. If an attribute name is passed, determine if that one attribute has changed.

NOTE: This will only be true if checked inside a handler while the various change events are firing. Once the change events are done, this will always return false. This has nothing to do with determining whether a property has changed since the last time it was saved to the server.

changedAttributes state.changedAttributes([objectToDiff])

Return an object containing all the attributes that have changed, or false if there are no changed attributes. Useful for determining what parts of a view need to be updated and/or what attributes need to be persisted to the server. Unset attributes will be set to undefined. You can also pass an attributes object to diff against the state, determining if there would be a change.

NOTE: When passing an attributes object to diff against, only changes to properties defined on the model will be detected. This means that changes to children or collections will not be returned as changes by this method.

NOTE: This will only return values if checked inside a handler for "change" events (i.e. while the events are firing). Once the change events are done, this will always return an empty object. This has nothing to do with determining which properties have been changed since the last time it was saved to the server.

toJSON state.toJSON()

Return a shallow copy of the state's attributes for JSON stringification. This can be used for persistence, serialization, or augmentation, before being sent to the server.

(The name of this method is a bit confusing, as it doesn't actually return a JSON string—but I'm afraid that it's the way that the JavaScript API for JSON.stringify works.)

Calls serialize to determine which values to return in the object. Will be called implicitly by JSON.stringify.

var me = new Person({ firstName: 'Phil', lastName: 'Roberts' });

me.toJSON() //=> { firstName: 'Phil', lastName: 'Roberts' }

//JSON.stringify implicitly calls toJSON:
JSON.stringify(me) //=> "{\"firstName\":\"Phil\",\"lastName\":\"Roberts\"}"

getAttributes state.getAttributes([options, raw])

Returns a shallow copy of the state's attributes while only including the types (props, session, derived) specified by the options parameter. The desired keys should be set to true on options (props, session, derived) if attributes of that type should be returned by getAttributes.

The second parameter, raw, is a boolean that specifies whether returned values should be the raw value or should instead use the getter associated with its dataType. If you are using getAttributes to pass data to a template, most of the time you will not want to use the raw parameter, since you will want to take advantage of any built-in and custom dataTypes on your state instance.

var Person = AmpersandState.extend({
  props: {
      firstName: 'string',
      lastName: 'string'
  },
  session: {
    lastSeen: 'date',
    active: 'boolean'
  },
  derived: {
    fullName: {
      deps: ['firstName', 'lastName'],
      fn: function () {
        return this.firstName + ' ' + this.lastName;
      }
    }
  }
});

var me = new Person({ firstName: 'Luke', lastName: 'Karrys', active: true, lastSeen: 1428430444479 });

me.getAttributes({derived: true}) //=> { fullName: 'Luke Karrys' }

me.getAttributes({session: true}) //=> { active: true, lastSeen: Tue Apr 07 2015 11:14:04 GMT-0700 (MST) }
me.getAttributes({session: true}, true) //=> { active: true, lastSeen: 1428430444479 }

me.getAttributes({
  props: true,
  session: true,
  derived: true
}) //=> { firstName: 'Luke', lastName: 'Karrys', active: true, lastSeen: Tue Apr 07 2015 11:14:04 GMT-0700 (MST), fullName: 'Luke Karrys' }

Credits

@HenrikJoreteg

License

MIT

changelog

  • 5.0.2 - use lodash/xzy pkg requires (@samhashemi)

ampersand-state's People

Contributors

bear avatar cdaringe avatar chesles avatar conradz avatar dhritzkiv avatar dminkovsky avatar fbaiodias avatar henrikjoreteg avatar hpneo avatar hzlmn avatar jackboberg avatar janpaul123 avatar jgillich avatar jvduf avatar kamilogorek avatar latentflip avatar lukekarrys avatar mmacaula avatar nathanstitt avatar nlf avatar pgilad avatar samhashemi avatar spencerbyw avatar strml avatar svenheden avatar themindfuldev avatar timwis avatar wraithgar avatar zearin avatar zweinz avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

ampersand-state's Issues

Feature idea: Using Joi for prop definition and validation, or adding Joi-like features

Have you guys used Joi? I use Joi on the server-side with Hapi and really love it.

I just got started with Ampersand and also really, really love ampersand-state. What a great tool. I was just in the middle of defining some properties on a view (!!!) and wanted to define valid values for one properties a function of the value of another property. Joi lets you do this with alternatives and references.

If Joi was adopted for properties, it could then also be used for validation. It would be a great win, I think. Defining a properties schema already gets you quite close to using that schema for validation.

This wouldn't be trivial, of course. But I wanted to get this out there to see what you think. Joi is apparently browser-compatible via browserify, though they don't run browser tests and they support things like Buffers. So, Joi may be too heavy for this purpose, and if so, perhaps just the above-mentioned reference/alternatives features could be added. And validation via properties' schemas, I think, would be great too.

Thank you.

Circular dependencies model -> children -> collection with same model

I've been trying to implement a circular dependency (https://gist.github.com/dazld/e6a04574ef10e81fe9ab) - but as written, this doesn't appear to work.

I've tried to replace the model property of the collection with a function that requires and instantiates, but without success.

I also tried using the collections property, but this means that events from the collection/child will not bubble up, which isn't so useful.

Is this something that ampersand just cannot support, or is there a technique which works..? I'm using webpack as a compiler.

Instantiating a model with collections should use add.

Hi, ran into an interesting problem today where I have a model that defines a collection of models that define their own idAttribute. In order to get the collection to de-duplicate it's children on instantiation I have to set mainIndex on the collection and pass { remove: false } to the parent model's constructor.

Here's some code:

var State = require('ampersand-state');
var Collection = require('ampersand-collection');

var Child = State.extend({
    idAttribute: 'idx',
    props: {
        idx: ['string']
    }
});

var GoodCollection = Collection.extend({
    mainIndex: 'idx',
    model: Child
});

var BadCollection = Collection.extend({
    model: Child
});

var Parent = State.extend({
    props: {
        name: ['string']
    },
    collections: {
        childA: GoodCollection
        childB: BadCollection
    }
});

var mom = new Parent({
    name: 'Mom',
    childrenA: [{
        idx: 'abc'
    }, {
        idx: 'abc'
    }, {
        idx: 'def'
    }],
    childrenB: [{
        idx: 'abc'
    }, {
        idx: 'abc'
    }, {
        idx: 'def'
    }]
}, { remove: false });
// mom.childrenA.length = 2
// mom.childrenB.length = 3

var dad = new Parent({
    name: 'Dad',
    childrenA: [{
        idx: 'abc'
    }, {
        idx: 'abc'
    }, {
        idx: 'def'
    }],
    childrenB: [{
        idx: 'abc'
    }, {
        idx: 'abc'
    }, {
        idx: 'def'
    }]
});
// dad.childrenA.length = 3
// dad.childrenB.length = 3

… this may also be related to ampersand-collection issue 19 and ampersand-collection issue 20. I believe that a solutions would be to split the if statement at line 143 and in the case of a collection call .add instead of set (it defaults the options differently than .set does itself).

cc @jedireza

Instances of a child get ignored on construction

Consider the following (edit / run on requirebin here)

var AmpersandState = require('ampersand-state');

var Smile = AmpersandState.extend({
  props: {
    width: 'number' 
  }
});

var Person = AmpersandState.extend({
  props: {
    foo: 'string'
  },
  children: {
    smile: Smile
  }
});

var bobsSmile = new Smile({ width: 100 });
var bob = new Person({ smile: bobsSmile });

// in Person.prototype._initChildren a new instance gets created instead of using the existing one
alert(""+ bobsSmile.cid + " !== " + bob.smile.cid);
// state1 !== state3

// causes heartbreak :(
bobsSmile.width = -100;

alert("" + bobsSmile.width + " !== " + bob.smile.width);
// -100 !== undefined

// there is a way for our friend to find back where his smile was, but why leave him depressed?
bob.smile = bobsSmile;

alert("" + bobsSmile.cid + " === " + bob.smile.cid);
// state1 === state1

Maybe this has done by design, but I can't help but think it's confusing and not the behaviour we're looking for. Ideally there should only be one instance of every resource representation across the app, otherwise keeping everything in sync would become a pain.

Required Props & Empty Attrs

Hi. I'm trying to create a read-only model with required properties. I would expect that attempting to instantiate such a model without the required properties in it's initialization object would throw an error, but it doesn't. I have the following:

var AmpState = require('ampersand-state');

var ReadOnlyModel = AmpState.extend({
    idAttribute: 'requiredProp',
    props: {
        requiredProp: {
            type: 'string',
            required: true,
            allowNull: false,
            setOnce: true
        }
});

var oneModel = new ReadOnlyModel({});

Shouldn't that throw an error?

Better handling of multiple collections.

I'd like to switch to some way of better handling collections that contain a model. Perhaps adding a addCollectionReference method that the collection calls if present.

If child collections add data to themselves during initialization, they get cleared out by parent

the test case looks like this:

test('child collections should not be cleared if they add data to themselves when instantiated', function (t) {
    var Widget = State.extend({
        props: {
            title: 'string'
        }
    });
    var Widgets = Collection.extend({
        initialize: function () {
            // some collections read from data they have immediate access to
            // like localstorage, or whatnot. This should not be wiped out
            // when instantiated by parent.
            this.add([{title: 'hi'}]);
        },
        model: Widget
    });
    var Parent = State.extend({
        collections: {
            widgets: Widgets
        }
    });
    var parent = new Parent();

    t.equal(parent.widgets.length, 1, 'should contain data added by initialize method of child collection');
    t.end();
});

and fails.

Type casting

It'd be nice to have automatic type casting when setting certain property values that aren't of the correct property data type, namely text to number or number to text.

For example: say we have a model like so:

{
    props: {
        cost: "number",
        month: "text"
    }
}

and we try to use set on the model:

var date = new Date();
model.set({
    cost: "24.01",
    month: date.getMonth()// returns 7
})
//throws: "TypeError: Property 'cost' must be of type number. Tried to set 24.01"

neither of the values are set (an error is thrown). Is there a way to do automatic typecasting?

Setting state property from undefined -> `state` breaks bubbling events on other props

var MyState = State.extend({
  props: {
    foo: 'state',
    bar: 'state'
  }
});

var myState = new MyState({ foo: stateThing });

myState.on('change:stateThing.foo', function () { /*...*/ });

myState.bar = otherStateThing;

stateThing.foo = 'newValue'

//Event handler is not called

Why? Because: https://github.com/AmpersandJS/ampersand-state/blob/master/ampersand-state.js#L663

We stopListening on currentVal when changing a state prop, but here currentVal is undefined when setting bar, and stopListening(undefined) removes all handlers.

Change events should fire after derived properties have been updated.

Ampersand state fires its change events before it has updated cached derived properties. This makes accessing derived properties in change handlers tricky.

var X = State.extend({
     props: {
           foo: 'number'
     },
     derived: {
            bar: {
                   deps: ['foo'],
                   fn: function() { return this.foo + 1; }
            }
     }
});

var x = new X({ foo: 1 });

x.on('change:foo', function() {
       console.log(x.foo, x.bar);
}

x.foo = 2; // console log prints 2,2 instead of 2,3 because bar has not been recomputed yet.

'reject' for properties set after instantiation?

Even if you set extraProperties: 'reject', it's still possible to set a random property on the object later. Is it possible to throw an error when an undeclared property is set? (I'm not sure if there is any way to do this in JS but thought I'd ask..)

Also, btw, it the readme doesn't say what the default is for extraProperties

Derived Properties... Trail of thought

I've been reading the Human JavaScript book which is really opening my eyes to software development and consequently I've now been looking at Ampersand.

I've used Angular, Ember and Backbone previously, the former 2 being too 'magic and/or abstract' for my liking, I've been looking at derived properties and feel that the current format could be streamlined using a little syntactical sugar that I got the inspiration stole from Ember and their overriding of the Function Prototype.

Current:

derived: {
    fullName: {
        deps: ['firstName', 'lastName'],
        fn: function () {
            return this.firstName + ' ' + this.lastName;
        },
        cache: false
    }
}

Proposed:

derived: {
    fullName: function () {
        return this.firstName + ' ' + this.lastName;
    }.deps('firstName', 'lastName').cache(false)
}

Not sure what your thoughts on this are and whether or not it's worth looking at as an alternative syntax for derived properties but personally I think this method also reads better than the current method.

Type mismatch when instantiating Ampersand View

Error

Run this:

var sb = new ScoreBoard( {model: bs, article: as} );

Get this:

Uncaught TypeError: Property 'el' must be of type element. Tried to set [object HTMLDivElement]

Stack trace

> Thrown on line 184 in ampersand-state.js

Trace:
_.extend.set
Object.defineProperty.set
_.extend.renderWithTemplate
_.extend.render
View
child
module.exports.init

Thrown on line 184 in ampersand-state.js. Sorry about lack of line numbers in the stack trace - our app is browserified.

Context

We are an embeddable application (widget) that loads on many websites on the internet.
The particular module (and resulting view) where this error occurs loads fine on all of our sites, apart from one.
The bug appears to stem from the following code:

> line 158, ampersand-state.js
// check type if we have one
if (dataType && dataType.set) {
    cast = dataType.set(newVal);
    newVal = cast.val;
    newType = cast.type;
}

I debugged the cast variable. On all correctly functioning sites, I get the following:

{val: div#mc_scoreboard.reset, type: "element"}

On the broken site:

{val: div#mc_scoreboard.reset, type: "object"}

Note "element" / "object". This appears to be causing the type mismatch.

Help!

We debugged as far as we could. Got to here:

> line 156, ampersand-state.js
dataType = this._dataTypes[def.type];

In this case, def.type = "element" (string). So it would appear that this._dataTypes has a key element whose contents can have set run on it.
Beyond this I got confused very quickly though, as _dataTypes appears to be extended from somewhere else.

Hoping that somebody who helped write this will know what's going on a lot faster than me!

Broken behaviour where multiple instances of a model are created with extraProperties='allow'

var State = require('./ampersand-state');

var Base = State.extend({
    extraProperties: 'allow'
});

var one = new Base({ a: 'one.a', b: 'one.b' });
var two = new Base({ a: 'two.a', b: 'two.b', c: 'two.c' });

console.log('one', {a: one.a, b: one.b}); //=> one { a: 'one.a', b: 'one.b' }
console.log('two', {a: two.a, b: two.b, c: two.c}); => two { a: undefined, b: undefined, c: 'two.c' }

it seems that when an instance of a model is created with allowProperties, future instances cannot use the same property names or they will just return undefined.

Array modifier functions don't trigger change event

If I use a method like Array.prototype.push or Array.prototype.splice on an array in a model, it doesn't trigger a change event, since it's not directly setting the array. This is definitely an edge case, but it'd be cool if doing model.array.push(foo) just worked. I'm working around it by adding this to my view:

addItem: function (item) {
    var arr = _.clone(this.model.array);
    arr.push(item);
    this.model.array = arr;
}

The only solution I can think of would be that when there's an array in a model, those sort of methods are extended with some extra code, and then, using push as an example, Array.prototype.push.apply(this, arguments) is called at the end. I'm not sure if it's worth the effort to do that though, as it's not a super common case, and adding a view method isn't that bad. I figured I'd report it though, just for potential feedback. Thanks!

Documentation misleading

Only the children property is described, but not collections. If you try to add a collection using children instead of collections you get unexpected behavior (http://requirebin.com/?gist=25f44b578210b3d6d240)

Either children needs to handle collections (implied in documentation?) or the collections property needs to be documented.

How to Run Tests?

Hello, I'm not familiar with testem, and I can't seem to get tests working. I would like to create a PR to fix another issue I reported, and show tests failing/passing. When I clone my fork, run npm i and npm test I get this:

> [email protected] test /Users/matthayes/Sites/github/mysterycommand/ampersand-state
> browserify test/index.js | tape-run | tap-spec

no matches for phantom/*
  Fail!
npm ERR! Test failed.  See above for more details.
npm ERR! not ok code 0

I thought maybe it's looking for a PhantomJS install, but after installing, same error … I looked at the Test'em repo where it says to npm -g i testem, so I tried that … same error.

Also, there's no wiki. Any help?

Passing an initialized collection to a State constructor

When you pass an initialized collection to a State constructor it won't detect that the collection is already initialized. Instead it will read it as an object and wrap it in a new collection.

Is there a way to bypass the auto-initialization in constructors.
Could this be achieved by type-checking?

Example which does not work:
var myCollection = new MyCollection();
var myState = new MyState({someChildCollection: myCollection });

Exampe that does work:
var myCollection = new MyCollection();
var myState = new MyState();
myState.someChildCollection = myCollection;

Question: Contributing Guide

Is there an official Contributing Guide for ampersand-state? In the lieu of one, should future contributors adhere to this guide as per ampersandjs.com's #74?

Derived property doesn't fire change events

Code from my model:

  props: {
    maxSaving: 'number',
    target: 'number',
    saving: 'number',
    availableWidth: 'number'
  },

  derived: {
    targetOffset: {
      deps: ['availableWidth', 'target', 'maxSaving'],
      fn: function () {
        return (this.target / this.maxSaving) * this.availableWidth;
      }
    }
  },

When I set a property like model.target, it fires a change:target event, but it doesn't fire a change:targetOffset event. It should, right?

If I manually read the property using console.log(model.targetOffset) before and after editing model.target, I can verify that the derived value definitely has changed. But there's no event.

Am I doing something wrong?

Borrow features from Backbone.Epoxy

Been working on a Backbone app for the last year, and Ampersand looks like it solves a lot of the issues I've run into. I've been using the Backbone.Epoxy plugin, which has enhanced Model and View classes.

Epoxy.Model includes computed properties, which AmpersandState has as well. However, the main thing that AmpersandState is missing compared to Epoxy is the ability to set computed properties. Here's an example from the Epoxy docs:

model.addComputed("formattedPrice", {
    deps: ["price"],
    get: function( price ) {
        return "$"+ price;
    },
    set: function( value ) {
        return {price: parseInt(value.replace("$", ""))}
    }
});

Not necessarily a critical feature, but I could see it being useful.

Epoxy also automatically determines dependencies by parsing or listening for .get() calls while initializing a computed property, although that one I definitely don't see as critical.

Array cannot default to array

AmpersandModel.extend({
    props: {
        checkpoints: { type: 'array', default: [] }
    }
});

TypeError: The default value for checkpoints cannot be an object/array, must be a value or a function which returns a value/object/array

I should be able to fix it if I get confirmation that this is actually is a bug.

object and array props cannot be updated

When declaring a property as object or array, the property cannot be updated properly without setting the entire prop. That is, cannot alter a key in the object or push to the array.

var Model = require('./ampersand-state.js');
var Foo = Model.extend({
  props: { 
    bar: {
      type: 'object', 
      required: true
    } 
  } 
});

var foo = new Foo();
foo.bar.baz = "asdf";
foo.toJSON(); // =>  { bar: {} }
var Model = require('./ampersand-state.js');
var Foo = Model.extend({
  props: { 
    bar: {
      type: 'array', 
      required: true
    } 
  } 
});
var foo = new Foo();
foo.bar[0] = 'asdf';
foo.toJSON();  // => { bar: [] }

Derived properties don't listen to change events from collections

Simplified example:

var Model = require('ampersand-model');
var Collection = require('ampersand-collection');

var Room = Model.extend({
  props: {
    size: ['number', true, 10],
  },
});

var Rooms = Collection.extend({
  model: Room,
});

var House = Model.extend({
  collections: {
    rooms: Rooms,
  },

  derived: {
    totalArea: {
      deps: ['rooms'],
      fn: function() {
        return this.rooms.reduce(function(a, b) {
          return a + b.size;
        }, 0);
      }
    }
  },
});

var house = new House({
  rooms: [{size: 12}]
});

console.log(house.totalArea); // => 12
house.rooms.at(0).size = 15;
console.log(house.totalArea); // => 12

Seems like this should work?

`collections` is not documented

I was looking at children docs and wondered if state objects support collections too. I looked for a subsection called "collections", and to my disappointment didn't find one.

But then, lo and behold, the section on children mentions collections. I looked in the code and yup, extend() examines a collections key that is then passed to _initCollections().

Should this be documented? If yes, I'd volunteer for this but I'm about an hour in to working with Ampersand.

Computed default properties should be idempotent

If a property is undefined but has a default value, we reference the default every time the property is accessed. Where the default value is returned from a function, this means that the value is calculated on every single access. This creates a problem for computed, non-deterministic defaults. I expect this also creates problems for array and object properties, as we repeatedly create new objects and discard references to the previous object.

For example, I created a model that generates a UUID if no ID is provided. I would expect this value to remain the same, but I see the following:

var Test = Model.extend({
    props: {
        id: {
            type: "string",
            default: function() {
                return uuid.v4();
            }
        }
    }
});

var t = new Test();
t.id === t.id; // false

It would be better if property defaults behaved the same way as defaults in Backbone - i.e. applied to the model only once, on instantiation.

Listen to other events rather than just 'change'

Currently event bubbling only works for change events (see _getEventBubblingHandler).

In order to emit more custom event types, I had to do the following (notice the else clause):

// base.js
module.exports = State.extend({
    // copy ampersand-state bubble event to support custom event
    _bubbleEvent: function (propertyName) {
        return _.bind(function (name, model, newValue) {
            if (/^change:/.test(name)) {
                this.trigger('change:' + propertyName + '.' + name.split(':')[1], model, newValue);
            } else if (name === 'change') {
                this.trigger('change', this);
            } else {
                this.trigger(name, model, newValue);
            }
        }, this);
    }
});

and then use it like so

// parentView.js
    render: function () {
        this.renderWithTemplate(this);
        this.child = new ChildView();
        this.renderSubview(this.child, this.el);
        this.listenTo(this.child, 'all', this._bubbleEvent('child'));
        return this;
    }

This is sort of a hack, but I can see how this can be useful for other scenarios as well. Would it be good to start supporting more events? It doesn't have to follow the change:property convention, just the event name would be helpful.

Reserved Property Names in Documentation

Some property names are reserved, such as "type"
For example, defining model.props.type in a model causes an error
Please create a list of reserved property names in the documentation

Default array/object values.

I'm not getting array and object data types to initialize to the default empty values.

Specifically, this fails:

test('default array/object values', function (t) {
    var foo = new Foo({
        firstName: 'jim',
        lastName: 'tom'
    }); 

    t.ok(foo.list !== undefined);
    t.ok(foo.hash !== undefined);
    t.end();
});

Derived properties with .collection and/or .parent dependency

I'm currently trying to use the following example.

   derived{
         picked: {
                deps: ['collection.parent'],
                fn: function () {
                    return this.collection.parent.isPicked(this);
                },
                cache: false
            },
     }

However, it does not trigger any event on this object that "picked" has changed. Is this intended? Is there a way to let this work?

As a workaround i removed the derived and changed it into:

{
        session:{
            picked: ['boolean', true, false],
        },

        initialize : {
            this.listenTo(this.collection.parent, "change:picked", function() {
                 this.picked = this.collection.parent.isPicked(this);
        });
}

It seems i'm also having an issue listening to a collection with a derived property.

        collections: {
            options: OptionCollection
        },
        derived: {
            top: {
                deps: ['options'],
                fn: function () {
                    console.log("running derived options");
                    return this.getTop();
                }
            }
        }

Way to extend

I'm struggling to get Ampersand work with Typescript. The ampersand-collection I got to work by using the initialize() code and re-running this._reset();

However, extending the Ampersand.State base class by not using the Ampersand.State.extend() function seems impossible as it does a lot more like parsing the "props". I think we need a way to extend the base class by simple prototype and still have a way to define "props" etc. In the current situation it's impossible to use Ampersand with Typescript, which is a shame...

Should not instantiate collections with empty array

doing this:

this[coll] = new this._collections[coll]([], {parent: this});

means that if we in the collection's initialize method add some models by using add() they'll be removed by the subsequent reset call.

At first glance i think the problem would be solved by just doing:

this[coll] = new this._collections[coll](null, {parent: this});

But this needs some tests to confirm.

previousAttributes() and defaults bug

The default value of a property that is changed the first time always returns 'undefined'. This seems to apply to a tleast booleans and arrays, but most likely any property.

Example

When having two properties.

props{
      test1: ['boolean', true, true],
      test2: ['boolean', true, true],
}

Listening to the following change:

test1 = false;

model.previousAttributes() returns an object like this:

{
    test1: undefined,
    test2: true
}

Custom validation functions for props?

Would you be interested in a PR to add per-property validation? So instead of an array of allowed values, you could provide a function that would return true or false, and Ampersand State would throw an error if you try to set a value that doesn't pass.

e.g.

props: {
  somethingThatHasToBeEven: {
    type: 'number',
    validate: function (value) {
       return value % 2 === 0;
    }
  }
}

Constants and/or read-only attributes

There are lots of apis out there that, for reasons outside the scope of ampersand caring, have attributes that are either constants (always the same for models of this type) or read only (will never and can never change for a given model instance).

Currently for constants one can set the prop to {type: 'string', values: ['constantValue']} but that seems a little longhanded for a constant.

Also, this doesn't address the issue of a value that needs to be in props (i.e. included in state syncs to the server) but should generate helpful errors clientside if changed to help protect you from yourself.

This issue is a discussion/feature request for these things.

Calling set with { unset: true } behaves incorrectly.

var Person = AmpersandState.extend({
  props: {
    firstName: 'string'
  }
});

var me = new Person({ firstName: 'phil' });
me.on('change:firstName', console.log.bind(console));

me.set({ firstName: 'foo' }, { unset: true });

//logs -> {me}, "foo"
//should log -> {me}, undefined

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.