ampersand-state
Advanced tools
Comparing version 4.2.7 to 4.3.0
@@ -9,7 +9,8 @@ var _ = require('underscore'); | ||
options || (options = {}); | ||
if (!this.cid) this.cid = _.uniqueId('state'); | ||
this.cid = _.uniqueId('state'); | ||
this._values = {}; | ||
this._definition = Object.create(this._definition); | ||
if (options.parse) attrs = this.parse(attrs, options); | ||
if (options.parent) this.parent = options.parent; | ||
this.parent = options.parent; | ||
this.collection = options.collection; | ||
this._keyTree = new KeyTree(); | ||
@@ -32,2 +33,8 @@ this._initCollections(); | ||
idAttribute: 'id', | ||
namespaceAttribute: 'namespace', | ||
typeAttribute: 'modelType', | ||
// Stubbed out to be overwritten | ||
@@ -38,2 +45,35 @@ initialize: function () { | ||
// Get ID of model per configuration. | ||
// Should *always* be how ID is determined by other code. | ||
getId: function () { | ||
return this[this.idAttribute]; | ||
}, | ||
// Get namespace of model per configuration. | ||
// Should *always* be how namespace is determined by other code. | ||
getNamespace: function () { | ||
return this[this.namespaceAttribute]; | ||
}, | ||
// Get type of model per configuration. | ||
// Should *always* be how type is determined by other code. | ||
getType: function () { | ||
return this[this.typeAttribute]; | ||
}, | ||
// A model is new if it has never been saved to the server, and lacks an id. | ||
isNew: function () { | ||
return this.getId() == null; | ||
}, | ||
// get HTML-escaped value of attribute | ||
escape: function (attr) { | ||
return _.escape(this.get(attr)); | ||
}, | ||
// Check if the model is currently in a valid state. | ||
isValid: function (options) { | ||
return this._validate({}, _.extend(options || {}, { validate: true })); | ||
}, | ||
// Parse can be used remap/restructure/rename incoming properties | ||
@@ -40,0 +80,0 @@ // before they are applied to attributes. |
{ | ||
"name": "ampersand-state", | ||
"description": "An observable, extensible state object with derived watchable properties.", | ||
"version": "4.2.7", | ||
"version": "4.3.0", | ||
"author": "Henrik Joreteg <henrik@andyet.net>", | ||
@@ -6,0 +6,0 @@ "bugs": { |
464
README.md
# ampersand-state | ||
<!-- starthide --> | ||
Part of the [Ampersand.js toolkit](http://ampersandjs.com) for building clientside applications. | ||
<!-- endhide --> | ||
An observable, extensible state object with derived watchable properties. | ||
@@ -9,7 +13,4 @@ | ||
<!-- starthide --> | ||
Part of the [Ampersand.js toolkit](http://ampersandjs.com) for building clientside applications. | ||
<!-- endhide --> | ||
For further explanation see the [learn ampersand-state](http://ampersandjs.com/learn/state) guide. | ||
<!-- starthide --> | ||
## browser support | ||
@@ -19,3 +20,2 @@ | ||
](https://ci.testling.com/ampersandjs/ampersand-state) | ||
<!-- endhide --> | ||
@@ -28,61 +28,26 @@ ## Install | ||
<!-- starthide --> | ||
## In pursuit of the ultimate observable JS object. | ||
## API Reference | ||
So much of building an application is managing state. Your app needs a single unadulterated *source of truth*. But in order to fully de-couple it from everything that cares about it, it needs to be observable. | ||
### extend `AmpersandState.extend({ })` | ||
Typically that's done by allowing you to register handlers for when things change. | ||
To create a **State** class of your own, you extend **AmpersandState** and provide instance properties an options for your class. Typically here you will pass any properties (`props`, `session` and `derived` of your state class, and any instance methods to be attached to instances of your class. | ||
In our case it looks like this: | ||
**extend** correctly sets up the prototype chain, so that subclasses created with **extend** can be further extended as many times as you like. | ||
```js | ||
// Require the lib | ||
var State = require('ampersand-state'); | ||
Definitions like `props`, `session`, `derived` etc will be merged with superclass definitions. | ||
// Create a constructor to represent the state we want to store | ||
var Person = State.extend({ | ||
```javascript | ||
var Person = AmpersandState.extend({ | ||
props: { | ||
name: 'string', | ||
isDancing: 'boolean' | ||
} | ||
}); | ||
// Create an instance of our object | ||
var person = new Person({name: 'henrik'}); | ||
// watch it | ||
person.on('change:isDancing', function () { | ||
console.log('shake it!'); | ||
}); | ||
// set the value and the callback will fire | ||
person.isDancing = true; | ||
``` | ||
## So what?! That's boring. | ||
Agreed. Though, there is some more subtle awesomeness in being able to observe changes that are set with a simple assigment: `person.isDancing = true` as opposed to `person.set('isDancing', true)` (either works, btw), but that's nothing groundbreaking. | ||
So, what else? Well, as it turns out, a *huge* amount of code that you write in a project is really in describing and tracking relationships between variables. | ||
So, what if our observable layer did that for us too? | ||
Say you wanted to describe a draggable element on a page so you wanted it to follow a set of a rules. You want it to only be considered to have been dragged if it's total delta is > 10 pixels. | ||
```js | ||
var DraggedElementModel = State.extend({ | ||
props: { | ||
x: 'number', | ||
y: 'number' | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
session: { | ||
signedIn: ['boolean', true, false], | ||
}, | ||
derived: { | ||
// the name of our derived property | ||
dragged: { | ||
// the properties it depends on | ||
deps: ['x', 'y'], | ||
// how it's calculated | ||
fullName: { | ||
deps: ['firstName', 'lastName'], | ||
fn: function () { | ||
// the distance formula | ||
return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)) > 10; | ||
return this.firstName + ' ' + this.lastName; | ||
} | ||
@@ -92,384 +57,153 @@ } | ||
}); | ||
var element = new DraggedElementModel({x: 0, y: 0}); | ||
// now we can just watch for changes to "dragged" | ||
element.on('change:dragged', function (model, val) { | ||
if (val) { | ||
console.log('element has moved more than 10px'); | ||
} else { | ||
console.log('element has moved less than 10px'); | ||
} | ||
}); | ||
``` | ||
## You didn't invent derived properties, pal. `</sarcasm>` | ||
### constructor/initialize `new AmpersandState([attrs], [options])` | ||
True, derived properties aren't a new idea. But, being able to clearly declare and derive watchable properties from a model is super useful and in our case, they're just accessed without calling a method. For example, using the draggable example above, the derived property is just `element.dragged`. | ||
When creating an instance of a state object, you can pass in the initial values of the **attributes** which will be [set](#ampersand-state-set) on the model. Unless [extraProperties](#amperand-state-extra-properties) 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. | ||
## Handling relationships between objects/models with derived properties | ||
Say you've got an observable that you're using to model data from a RESTful API. Say that you've got a `/users` endpoint and when fetching a user, the user data includes a groupID that links them to another collection of groups that we've already fetched and created models for. From our user model we want to be able to easily access the group model. So, when passed to a template we can just access related group information. | ||
Cached, derived properties are perfect for handling this relationship: | ||
```js | ||
var UserModel = State.extend({ | ||
props: { | ||
name: 'string', | ||
groupId: 'string' | ||
}, | ||
derived: { | ||
groupModel: { | ||
deps: ['groupId'], | ||
fn: function () { | ||
// we access our group collection from within | ||
// the derived property to grab the right group model. | ||
return ourGroupCollection.get(this.groupId); | ||
} | ||
} | ||
} | ||
```javascript | ||
var me = new Person({ | ||
firstName: 'Phil' | ||
lastName: 'Roberts' | ||
}); | ||
var user = new UserModel({name: 'henrik', groupId: '2341'}); | ||
// now we can get the actual group model like so: | ||
user.groupModel; | ||
// As a bonus, it's even evented so you can listen for changes to the groupModel property. | ||
user.on('change:groupModel', function (model, newGroupModel) { | ||
console.log('group changed!', newGroupModel); | ||
}); | ||
me.firstName //=> Phil | ||
``` | ||
Available options: | ||
## Cached, derived properties are da shiznit | ||
* `[parse]` {Boolean} - whether to call the class's [parse](#ampersand-state-parse) function with the initial attributes. _Defaults to `false`_. | ||
* `[parent]` {AmpersandState} - pass a reference to a model's parent to store on the model. | ||
So, say you have a more "expensive" computation for model. Say you're parsing a long string for URLs and turning them into HTML and then wanting to reference that later. Again, this is built in. | ||
### idAttribute `model.idAttribute` | ||
By default, derived properties are cached. | ||
The attribute that should be used as the unique id of the model - typically the name of the property representing the model's id on the server. `getId` uses this to determine the id for use when constructing a model's url for saving to the server. | ||
```js | ||
// assume this linkifies strings | ||
var linkify = require('urlify'); | ||
Defaults to `'id'`. | ||
var MySmartDescriptionModel = State.extend({ | ||
// assume this is a long string of text | ||
description: 'string', | ||
derived: { | ||
linkified: { | ||
deps: ['description'], | ||
fn: function () { | ||
return linkify(this.description); | ||
} | ||
} | ||
} | ||
}); | ||
var myDescription = new MySmartDescriptionModel({ | ||
description: "Some text with a link. http://twitter.com/henrikjoreteg" | ||
}); | ||
// Now i can just reference this as many times as I want but it | ||
// will never run it through the expensive function again. | ||
myDescription.linkified; | ||
``` | ||
With the model above, the descrition will only be run through that linkifier method once, unless of course the description changes. | ||
## Derived properties are intelligently triggered | ||
Just because an underlying property has changed, *doesn't mean the derived property has*. | ||
Cached derived properties will *only* trigger a `change` if the resulting calculated value has changed. | ||
This is *super* useful if you've bound a derived property to a DOM property. This ensures that you won't ever touch the DOM unless the resulting value is *actually* different. Avoiding unecessary DOM changes is a huge boon for performance. | ||
This is also important for cases where you're dealing with fast changing attributes. | ||
Say you're drawing a realtime graph of tweets from the Twitter firehose, instead of binding your graph to increment with each tweet, if you know your graph only ticks with every thousand tweets you can easily create a property to watch. | ||
```js | ||
var MyGraphDataModel = State.extend({ | ||
var Person = AmpersandModel.extend({ | ||
idAttribute: 'personId', | ||
urlRoot: '/people', | ||
props: { | ||
numberOfTweets: 'number' | ||
}, | ||
derived: { | ||
thousandTweets: { | ||
deps: ['numberOfTweets'], | ||
fn: function () { | ||
return Math.floor(this.numberOfTweets / 1000); | ||
} | ||
} | ||
personId: 'number', | ||
name: 'string' | ||
} | ||
}); | ||
// then just watch the property | ||
var data = new MyGraphDataModel({numberOfTweets: 555}); | ||
var me = new Person({ personId: 123 }); | ||
// start adding 'em | ||
var increment = function () { | ||
data.number += 1; | ||
} | ||
setInterval(increment, 50); | ||
data.on('change:thousandTweets', function () { | ||
// will only get called every time is passes another | ||
// thousand tweets. | ||
}); | ||
console.log(me.url()) //=> "/people/123" | ||
``` | ||
## Derived properties don't *have* to be cached. | ||
### getId `model.getId()` | ||
Say you want to calculate a value whenever it's accessed. Sure, you can create a non-cached derived property. | ||
Get ID of model per `idAttribute` configuration. Should *always* be how ID is determined by other code. | ||
If you say `cache: false` then it will fire a `change` event anytime any of the `deps` changes and it will be re-calculated each time its accessed. | ||
### namespaceAttribute `model.namespaceAttribute` | ||
The property name that should be used as a namespace. Namespaces are completely optional, but exist in case you need to make an additionl distinction between models, that may be of the same type, with potentially conflicting IDs but are in fact different. | ||
## State can be extended as many times as you want | ||
Defaults to `'namespace'`. | ||
Each state object you define will have and `extend` method on the constructor. | ||
### getNamespace `model.getNamespace()` | ||
That means you can extend as much as you want and the definitions will get merged. | ||
Get namespace of model per `namespaceAttribute` configuration. Should *always* be how namespace is determined by other code. | ||
```js | ||
var Person = State.extend({ | ||
props: { | ||
name: 'string' | ||
}, | ||
sayHi: function () { | ||
return 'hi, ' + this.name; | ||
} | ||
}); | ||
### typeAttribute | ||
var AwesomePerson = Person.extend({ | ||
props: { | ||
awesomeness: 'number' | ||
} | ||
}); | ||
The property name that should be used to specify what type of model this is. This is optional, but specifying a model type types provides a standard, yet configurable way to determine what type of model it is. | ||
// Now awesome person will have both awesomeness and name properties | ||
var awesome = new AwesomePerson({ | ||
name: 'henrik', | ||
awesomeness: 8 | ||
}); | ||
Defaults to `'modelType'`. | ||
// and it will have the methods in the original | ||
awesome.sayHi(); // returns 'hi, henrik' | ||
### getType `model.getType()` | ||
// it also maintains the prototype chain | ||
// so instanceof checks will work up the chain | ||
Get type of model per `typeAttribute` configuration. Should *always* be how type is determined by other code. | ||
// so this is true | ||
awesome instanceof AwesomePerson; // true; | ||
### extraProperties `AmpersandState.extend({ extraProperties: 'allow' })` | ||
// and so is this | ||
awesome instanceof Person; // true | ||
Defines how properties that aren't defined in `props`, `session` or `derived` are handled. May be set to `'allow'`, `'reject'` or `'allow'`. | ||
``` | ||
```javascript | ||
var StateA = AmpersandState.extend({ | ||
extraProperties: 'allow', | ||
}); | ||
## child models and collections | ||
var stateA = new StateA({ foo: 'bar' }); | ||
stateA.foo === 'bar' //=> true | ||
You can declare children and collections that will get instantiated on init as follows: | ||
```js | ||
var State = require('ampersand-state'); | ||
var Messages = require('./models/messages'); | ||
var ProfileModel = require('./models/profile'); | ||
var Person = State.extend({ | ||
props: { | ||
name: 'string' | ||
}, | ||
collections: { | ||
// `Messages` here is a collection | ||
messages: Messages | ||
}, | ||
children: { | ||
// `ProfileModel` is another ampersand-state constructor | ||
profile: ProfileModel | ||
} | ||
var StateB = AmpersandState.extend({ | ||
extraProperties: 'ignore', | ||
}); | ||
// When we instantiate an instance of a Person | ||
// the Messages collection and ProfileModels | ||
// are instantiated as well | ||
var stateB = new StateB({ foo: 'bar' }); | ||
stateB.foo === undefined //=> true | ||
var person = new Person(); | ||
// so meetings exists as an empty collection | ||
person.meetings instanceof Meetings; // true | ||
// and profile exists as an empty `ProfileModel` | ||
person.profile instanceof ProfileModel; // true | ||
// This also provides some additional capabilities | ||
// when we instantiate a state object with some | ||
// data it will apply them to the collections and child | ||
// models as you might expect: | ||
var otherPerson = new Person({ | ||
messages: [ | ||
{from: 'someone', message: 'hi'}, | ||
{from: 'someoneElse', message: 'yo!'}, | ||
], | ||
profile: { | ||
name: 'Joe', | ||
hairColor: 'black' | ||
} | ||
var stateC = AmpersandState.extend({ | ||
extraProperties: 'reject' | ||
}); | ||
// now messages would have a length | ||
otherPerson.messages.length === 2; // true | ||
// and the profile state object would be | ||
// populated | ||
otherPerson.profile.name === 'Joe'; // true | ||
// The same works for `set`, it will apply it | ||
// to children as well. | ||
otherPerson.set({profile: {name: 'Mary'}}); | ||
// Since this a state object it triggers a `change:name` on | ||
// the `profile` object. | ||
// In addition, since it's a child that event propagates | ||
// up. More on that below. | ||
var stateC = new StateC({ foo: 'bar' }) | ||
//=> TypeError('No foo property defined on this model and extraProperties not set to "ignore" or "allow".'); | ||
``` | ||
## Event bubbling, derived properties based on children | ||
### collection `state.collection` | ||
Say you want a simple way to listen for any changes that are represented in a tempalate. | ||
A reference to the collection a state is in, if in a collection. | ||
Let's say you've got a `person` state object with a `profile` child. You want an easy way to listen for changes to either the base `person` object or the `profile`. In fact, you want to listen to anything related to the person object. | ||
This is used for building the default `url` property, etc. | ||
Rather than having to worry about watching the right thing, we do exactly what the browser does to solve this problem: we bubble up the events up the chain. | ||
Which is why you can do this: | ||
Now we can listen for deeply nested changes to properties. | ||
And we can declare derived properties that depend on children. For example: | ||
```js | ||
var Person = State.extend({ | ||
children: { | ||
profile: Profile | ||
}, | ||
derived: { | ||
childsName: { | ||
// now we can declare a child as a | ||
// dependency | ||
deps: ['profile.name'], | ||
fn: function () { | ||
return 'my child\'s name is ' + this.profile.name; | ||
} | ||
} | ||
} | ||
}); | ||
// some ampersand-rest-collection instance | ||
// with a `url` property | ||
widgets.url //=> '/api/widgets' | ||
var me = new Person(); | ||
// get a widget from our collection | ||
var badWidget = widgets.get('47'); | ||
// we can listen for changes to the derived property | ||
me.on('change:childsName', function (model, newValue) { | ||
console.log(newValue); // logs out `my child's name is henrik` | ||
}); | ||
// so when a property of a child is changed the callback | ||
// above will be fired (if the resulting derived property is different) | ||
me.profile.name = 'henrik'; | ||
// Without a `collection` reference this | ||
// widget wouldn't know what URL to build | ||
// when calling destroy | ||
badWidget.destroy(); // does a DELETE /api/widgets/47 | ||
``` | ||
## A quick note about instanceof checks | ||
### cid `state.cid` | ||
With npm and browserify for module deps you can sometimes end up with a situation where, the same `state` constructor wasn't used to build a `state` object. As a result `instanceof` checks will fail. | ||
A special property of states, the **cid**, or a 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 it's true **id** but needs a unique id so it can be rendered in the UI etc. | ||
In order to deal with this (because sometimes this is a legitimate scenario), `state` simply creates a read-only `isState` property on all state objects that can be used to check whether or a not a given object is in fact a state object no matter what its constructor was. | ||
<!-- endhide --> | ||
```javascript | ||
var userA = new User(); | ||
console.log(userA.cid) //=> "state-1" | ||
## API Reference | ||
var userB = new User(); | ||
console.log(userB.cid) //=> "state-2" | ||
``` | ||
### extend `AmpersandState.extend({ })` | ||
### isNew `model.isNew()` | ||
To create a **State** class of your own, you extend **AmpersandState** and provide instance properties an options for your class. Typically here you will pass any properties (`props`, `session` and `derived` of your state class, and any instance methods to be attached to instances of your class. | ||
Has this model been saved to the server yet? If the model does not yet have an id (using `getId()`), it is considered to be new. | ||
**extend** correctly sets up the prototype chain, so that subclasses created with **extend** can be further extended as many times as you like. | ||
### escape `model.escape()` | ||
Definitions like `props`, `session`, `derived` etc will be merged with superclass definitions. | ||
Similar to `get`, but returns the HTML-escaped version of a model's attribute. If you're interpolating data from the model into HTML, using **escape** to retrieve attributes will help prevent XSS attacks. | ||
```javascript | ||
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; | ||
} | ||
} | ||
} | ||
}); | ||
``` | ||
### 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](#ampersand-state-set) on the model. Unless [extraProperties](#amperand-state-extra-properties) 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. | ||
```javascript | ||
var me = new Person({ | ||
firstName: 'Phil' | ||
lastName: 'Roberts' | ||
var hacker = new PersonModel({ | ||
name: "<script>alert('xss')</script>" | ||
}); | ||
me.firstName //=> Phil | ||
document.body.innerHTML = hacker.escape('name'); | ||
``` | ||
Available options: | ||
### isValid `model.isValid()` | ||
* `[parse]` {Boolean} - whether to call the class's [parse](#ampersand-state-parse) function with the initial attributes. _Defaults to `false`_. | ||
* `[parent]` {AmpersandState} - pass a reference to a model's parent to store on the model. | ||
Check if the model is currently in a valid state, it does this by calling the `validate` method, of your model if you've provided one. | ||
### extraProperties `AmpersandState.extend({ extraProperties: 'allow' })` | ||
Defines how properties that aren't defined in `props`, `session` or `derived` are handled. May be set to `'allow'`, `'reject'` or `'allow'`. | ||
```javascript | ||
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 model and extraProperties not set to "ignore" or "allow".'); | ||
``` | ||
### dataTypes | ||
@@ -476,0 +210,0 @@ |
@@ -216,1 +216,59 @@ /*jshint expr: true*/ | ||
}); | ||
test('custom id and namespace attributes', function (t) { | ||
var NewPerson = State.extend({ | ||
props: { | ||
name: 'string', | ||
_id: 'number', | ||
ns: 'string' | ||
}, | ||
idAttribute: '_id', | ||
namespaceAttribute: 'ns' | ||
}); | ||
var person = new NewPerson({name: 'henrik', ns: 'group1', _id: 47}); | ||
t.equal(person.getId(), 47); | ||
t.equal(person.getNamespace(), 'group1'); | ||
t.end(); | ||
}); | ||
test('customizable `type` attribute', function (t) { | ||
var FirstModel = State.extend({ | ||
type: 'hello', | ||
typeAttribute: 'type' | ||
}); | ||
var SecondModel = State.extend({ | ||
modelType: 'second' | ||
}); | ||
var first = new FirstModel(); | ||
var second = new SecondModel(); | ||
t.equal(first.getType(), 'hello'); | ||
t.equal(second.getType(), 'second'); | ||
t.end(); | ||
}); | ||
test('constructor should be defined', function (t) { | ||
var Foo = State.extend({ | ||
props: { name: 'string' } | ||
}); | ||
var foo = new Foo(); | ||
t.ok(foo.constructor); | ||
t.end(); | ||
}); | ||
test('isValid is a thing', function (t) { | ||
var Foo = State.extend({ | ||
props: { name: ['string', true] }, | ||
validate: function (attrs) { | ||
if (attrs.name.length < 2) { | ||
return "can't be too short"; | ||
} | ||
} | ||
}); | ||
var foo = new Foo(); | ||
t.notOk(foo.isValid()); | ||
foo.name = 'thing'; | ||
t.ok(foo.isValid()); | ||
t.end(); | ||
}); |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
1913
80916
459