Comparing version 0.4.4 to 1.0.0-rc1
{ | ||
"name": "baobab", | ||
"main": "build/baobab.min.js", | ||
"version": "0.4.4", | ||
"version": "1.0.0-rc1", | ||
"homepage": "https://github.com/Yomguithereal/baobab", | ||
@@ -6,0 +6,0 @@ "author": { |
@@ -14,25 +14,10 @@ /** | ||
// Should the tree clone data when giving it back to the user? | ||
clone: false, | ||
// Facets registration | ||
facets: {}, | ||
// Which cloning function should the tree use? | ||
cloningFunction: null, | ||
// Validation specifications | ||
validate: null, | ||
// Should cursors be singletons? | ||
cursorSingletons: true, | ||
// Maximum records in the tree's history | ||
maxHistory: 0, | ||
// Collection of react mixins to merge with the tree's ones | ||
mixins: [], | ||
// Should the tree shift its internal reference when applying mutations? | ||
shiftReferences: false, | ||
// Custom typology object to use along with the validation utilities | ||
typology: null, | ||
// Validation specifications | ||
validate: null | ||
// Validation behaviour 'rollback' or 'notify' | ||
validationBehavior: 'rollback' | ||
}; |
@@ -12,3 +12,3 @@ /** | ||
Object.defineProperty(Baobab, 'version', { | ||
value: '0.4.4' | ||
value: '1.0.0-rc1' | ||
}); | ||
@@ -15,0 +15,0 @@ |
{ | ||
"name": "baobab", | ||
"version": "0.4.4", | ||
"version": "1.0.0-rc1", | ||
"description": "JavaScript data tree with cursors.", | ||
"main": "index.js", | ||
"dependencies": { | ||
"emmett": "^2.1.2", | ||
"typology": "^0.3.1" | ||
"emmett": "^3.0.0" | ||
}, | ||
@@ -19,6 +18,4 @@ "devDependencies": { | ||
"gulp-uglify": "^1.0.2", | ||
"jsdom": "^3.1.0", | ||
"lodash.clonedeep": "^3.0.0", | ||
"lodash": "^3.6.0", | ||
"mocha": "^2.0.1", | ||
"react": "^0.13.0", | ||
"vinyl-buffer": "^1.0.0", | ||
@@ -25,0 +22,0 @@ "vinyl-source-stream": "^1.1.0" |
646
README.md
@@ -9,6 +9,8 @@ [![Build Status](https://travis-ci.org/Yomguithereal/baobab.svg)](https://travis-ci.org/Yomguithereal/baobab) | ||
It can be paired with **React** easily through [mixins](#react-mixins) to provide a centralized model holding your application's state. | ||
It aims at providing a centralized model holding an application's state and can be paired with **React** easily through mixins or higher order components (available [here](https://github.com/Yomguithereal/baobab-react)). | ||
For a concise introduction about the library and how it can be used by a React/Flux application, you can head toward **@christianalfoni**'s [article](http://christianalfoni.github.io/javascript/2015/02/06/plant-a-baobab-tree-in-your-flux-application.html) on the subject. | ||
For a concise introduction about the library and how it can be used in a React/Flux application, you can head toward **@christianalfoni**'s [article](http://christianalfoni.github.io/javascript/2015/02/06/plant-a-baobab-tree-in-your-flux-application.html) on the subject. | ||
**Fun fact**: A [Baobab](http://en.wikipedia.org/wiki/Adansonia_digitata), or *Adansonia digitata*, is a very big and magnificient African tree. | ||
## Summary | ||
@@ -24,3 +26,2 @@ | ||
* [Events](#events) | ||
* [React mixins](#react-mixins) | ||
* [Advanced](#advanced) | ||
@@ -30,8 +31,9 @@ * [Polymorphisms](#polymorphisms) | ||
* [Options](#options) | ||
* [Facets](#facets) | ||
* [History](#history) | ||
* [Update specifications](#update-specifications) | ||
* [Chaining mutations](#chaining-mutations) | ||
* [Cursor combinations](#cursor-combinations) | ||
* [Data validation](#data-validation) | ||
* [Common pitfalls](#common-pitfalls) | ||
* [Philosophy](#philosophy) | ||
* [Migration](#migration) | ||
* [Contribution](#contribution) | ||
@@ -63,8 +65,8 @@ * [License](#license) | ||
If you want to use **Baobab** with node.js or browserify, you can use npm. | ||
If you want to use **Baobab** with node.js/io.js or browserify/webpack etc., you can use npm. | ||
```sh | ||
npm install baobab | ||
npm install baobab@1.0.0-rc1 | ||
# Or for the latest dev version | ||
# Or if you need the latest dev version | ||
npm install git+https://github.com/Yomguithereal/baobab.git | ||
@@ -82,5 +84,7 @@ ``` | ||
```js | ||
bower install baobab | ||
bower install baobab@1.0.0-rc1 | ||
``` | ||
The library (as a standalone) currently weights ~20ko minified and ~6ko gzipped. | ||
## Usage | ||
@@ -92,3 +96,3 @@ | ||
Creating a *baobab* is as simple as instantiating it with an initial data set (note that only objects or array should be given). | ||
Creating a tree is as simple as instantiating *Baobab* with an initial data set. | ||
@@ -141,6 +145,20 @@ ```js | ||
Rather, the tree will stack and merge every update order you give it and will only commit them at the next frame or next tick in node. | ||
Rather, the tree will stack and merge every update order you give it and will only commit them later on. | ||
This enables the tree to perform efficient mutations and to be able to notify any relevant cursors that the data they are watching over has changed. | ||
**Important**: Note that the tree will shift the references of the objects it stores in order to enable *immutabley* comparisons between one version of the state and another (this is especially useful when using things as such as React's [PureRenderMixin](https://facebook.github.io/react/docs/pure-render-mixin.html)). | ||
*Example* | ||
```js | ||
var tree = new Baobab({hello: 'world'}); | ||
var initialState = tree.get(); | ||
tree.set('hello', 'monde'}); | ||
// After asynchronous update... | ||
assert(initialState !== tree.get()); | ||
``` | ||
##### Tree level | ||
@@ -165,3 +183,3 @@ | ||
```js | ||
cursor.edit({hello: 'world'}); | ||
cursor.set({hello: 'world'}); | ||
``` | ||
@@ -173,2 +191,5 @@ | ||
cursor.set('hello', 'world'); | ||
// Nested path | ||
cursor.set(['one', 'two'], 'world'); | ||
``` | ||
@@ -179,3 +200,3 @@ | ||
```js | ||
cursor.remove(); | ||
cursor.unset(); | ||
``` | ||
@@ -187,2 +208,5 @@ | ||
cursor.unset('hello'); | ||
// Nested path | ||
cursor.unset(['one', 'two']); | ||
``` | ||
@@ -192,7 +216,15 @@ | ||
Obviously this will fail if target data is not an array. | ||
Obviously this will fail if the value at cursor is not an array. | ||
```js | ||
cursor.push('purple'); | ||
// Pushing several values | ||
cursor.push(['purple', 'orange']); | ||
// At key | ||
cursor.push('list', 'orange') | ||
// Nested path | ||
cursor.push(['one', 'two'], 'orange'); | ||
``` | ||
@@ -202,21 +234,80 @@ | ||
Obviously this will fail if target data is not an array. | ||
Obviously this will fail if the value at cursor is not an array. | ||
```js | ||
cursor.unshift('purple'); | ||
// Unshifting several values | ||
cursor.unshift(['purple', 'orange']); | ||
// At key | ||
cursor.unshift('list', 'orange') | ||
// Nested path | ||
cursor.unshift(['one', 'two'], 'orange'); | ||
``` | ||
*Splicing an array* | ||
Obviously this will fail if the value at cursor is not an array. | ||
```js | ||
cursor.splice([1, 1]); | ||
// Applying splice n times with different arguments | ||
cursor.splice([[1, 2], [3, 2, 'hello']]); | ||
// At key | ||
cursor.splice('list', [1, 1]) | ||
// Nested path | ||
cursor.splice(['one', 'two'], [1, 1]); | ||
``` | ||
*Applying a function* | ||
```js | ||
cursor.apply(function(currentData) { | ||
var inc = function(currentData) { | ||
return currentData + 1; | ||
}); | ||
}; | ||
cursor.apply(inc); | ||
// At key | ||
cursor.apply('number', inc) | ||
// Nested path | ||
cursor.apply(['one', 'two'], 'orange'); | ||
``` | ||
*Chaining functions through composition* | ||
For more details about this particular point, check [this](#chaining-mutations). | ||
```js | ||
var inc = function(currentData) { | ||
return currentData + 1; | ||
}; | ||
cursor.chain(inc); | ||
// At key | ||
cursor.chain('number', inc) | ||
// Nested path | ||
cursor.chain(['one', 'two'], 'orange'); | ||
``` | ||
*Shallowly merging objects* | ||
Obviously this will fail if the value at cursor is not an object. | ||
```js | ||
cursor.merge({hello: 'world'}); | ||
// At key | ||
cursor.merge('object', {hello: 'world'}) | ||
// Nested path | ||
cursor.apply(['one', 'two'], {hello: 'world'}); | ||
``` | ||
@@ -228,3 +319,3 @@ | ||
Note however that only relevant cursors will be notified of data change. | ||
Note however that **only** relevant cursors will be notified of a change. | ||
@@ -272,3 +363,8 @@ Events can be bound to either the tree or cursors using the `on` method. | ||
```js | ||
tree.on('update', fn); | ||
tree.on('update', function(e) { | ||
var affectedPaths = e.data.log, | ||
previousState = e.data.previousState; | ||
//... | ||
}); | ||
``` | ||
@@ -278,6 +374,8 @@ | ||
Will fire if a data-validation specification was passed at instance and if new data does not abide by those specifications. For more information about this, see the [data validation](#data-validation) part of the documentation. | ||
Will fire if the `validate` function (see [options](#options)) returned an error for the current update. | ||
```js | ||
tree.on('invalid', fn); | ||
tree.on('invalid', function(e) { | ||
console.log(e.data.error); | ||
}); | ||
``` | ||
@@ -289,3 +387,3 @@ | ||
Will fire if data watched by cursor has updated. | ||
Will fire if data watched over by the cursor has updated. | ||
@@ -306,3 +404,3 @@ ```js | ||
Will fire if the cursor is irrelevant but becomes relevant again. | ||
Will fire if the cursor was irrelevant but becomes relevant again. | ||
@@ -317,166 +415,2 @@ ```js | ||
#### React mixins | ||
A *baobab* tree can easily be used as a UI model keeping the whole application state. | ||
It is then really simple to bind this centralized model to React components by using the library's built-in mixins. Those will naturally bind components to one or more cursors watching over parts of the main state so they can update only when relevant data has been changed. | ||
This basically makes the `shouldComponentUpdate` method useless in most of cases and ensures that your components will only re-render if they need to because of data changes. | ||
##### Tree level | ||
You can bind a React component to a Baoba tree cursors. Cursors bound to a component will have their values attached to `this.state.cursor` (or `this.state.cursors` if registering multiple cursors). | ||
###### Single cursor binding: | ||
Registering a cursor is as simple as defining a path. | ||
```jsx | ||
var tree = new Baobab({ | ||
users: ['John', 'Jack'], | ||
information: { | ||
title: 'My fancy App' | ||
} | ||
}); | ||
var UserList = React.createClass({ | ||
mixins: [tree.mixin], | ||
cursor: ['users'], | ||
render: function() { | ||
// Cursor data is then available either through: | ||
var data = this.cursor.get(); | ||
// Or | ||
var data = this.state.cursor; | ||
return <ul>{data.map(function(username) { | ||
return <li>{username}</li>; | ||
})}</ul>; | ||
} | ||
}); | ||
``` | ||
You can also use a function returning a cursor. This allows the component to be bound to a cursor passed through `this.props`, for instance. | ||
```jsx | ||
var tree = new Baobab({ | ||
users: ['John', 'Jack'], | ||
information: { | ||
title: 'My fancy App' | ||
} | ||
}); | ||
var UserGroupList = React.createClass({ | ||
mixins: [tree.mixin], | ||
cursor: function() { | ||
return this.props.usersCursor; | ||
}, | ||
render: function() { | ||
return <ul>{this.cursor.get().map(function(username) { | ||
return <li>{username}</li>; | ||
})}</ul>; | ||
} | ||
} | ||
var users = tree.select('users'); | ||
React.render( | ||
<UserGroupList usersCursor={users}/>, | ||
document.getElementById('mount-point') | ||
); | ||
``` | ||
###### Multiple cursors binding: | ||
Similarly, to bind several cursors to a component, you can bind your component to a list of cursors. | ||
```jsx | ||
var tree = new Baobab({ | ||
users: ['John', 'Jack'], | ||
information: { | ||
title: 'My fancy App' | ||
} | ||
}); | ||
var UserList = React.createClass({ | ||
mixins: [tree.mixin], | ||
cursors: [['users'], ['information', 'title']], | ||
render: function() { | ||
return ( | ||
<div> | ||
<h1>{this.cursors[1].get()}</h1> | ||
<ul>{this.cursors[0].get().map(function(name) { | ||
return <li>{name}</li>; | ||
})}</ul> | ||
</div> | ||
); | ||
} | ||
}); | ||
``` | ||
To access cursors in an easier way, you can also bind your component to a map of cursors. | ||
```jsx | ||
var tree = new Baobab({ | ||
users: ['John', 'Jack'], | ||
information: { | ||
title: 'My fancy App' | ||
} | ||
}); | ||
var UserList = React.createClass({ | ||
mixins: [tree.mixin], | ||
cursors: { | ||
users: ['users'], | ||
title: ['information', 'title'] | ||
}, | ||
render: function() { | ||
return ( | ||
<div> | ||
<h1>{this.cursors.title.get()}</h1> | ||
<ul>{this.cursors.users.get().map(function(name) { | ||
return <li>{name}</li>; | ||
})}</ul> | ||
</div> | ||
); | ||
} | ||
}); | ||
``` | ||
Same with a function: | ||
```jsx | ||
var tree = new Baobab({ | ||
users: ['John', 'Jack'], | ||
information: { | ||
title: 'My fancy App' | ||
} | ||
}); | ||
var UserList = React.createClass({ | ||
mixins: [tree.mixin], | ||
cursors: function() { | ||
return { | ||
users: this.props.usersCursor, | ||
title: ['information', 'title'] | ||
} | ||
}, | ||
render: function() { | ||
return ( | ||
<div> | ||
<h1>{this.cursors.title.get()}</h1> | ||
<ul>{this.cursors.users.get().map(function(name) { | ||
return <li>{name}</li>; | ||
})}</ul> | ||
</div> | ||
); | ||
} | ||
}); | ||
var users = tree.select('users'); | ||
React.render( | ||
<UserList usersCursor={users}/>, | ||
document.getElementById('mount-point') | ||
); | ||
``` | ||
### Advanced | ||
@@ -493,2 +427,3 @@ | ||
colors: ['blue', 'yellow', 'green'], | ||
currentColor: 1, | ||
items: [{id: 'one', value: 'Hey'}, {id: 'two', value: 'Ho'}] | ||
@@ -504,6 +439,6 @@ } | ||
// Retrieving data | ||
colorsCursor.get(1) | ||
colorsCursor.get(1); | ||
>>> 'yellow' | ||
paletteCursor.get('colors', 2) | ||
paletteCursor.get('colors', 2); | ||
>>> 'green' | ||
@@ -529,2 +464,13 @@ | ||
>>> 'Hey' | ||
// Retrieving or selecting data by using the value of another cursor | ||
var currentColorCursor = tree.select('colors', {$cursor: ['currentColor']}); | ||
var currentColor = tree.get('colors', {$cursor: ['currentColor']}); | ||
// Creating a blank tree | ||
var blankTree = new Baobab(); | ||
// You despise "new"? | ||
var tree = Baobab(); | ||
``` | ||
@@ -573,3 +519,3 @@ | ||
var rootCursor = tree.root(); | ||
var rootCursor = tree.root; | ||
// or | ||
@@ -579,3 +525,3 @@ var rootCursor = cursor.root(); | ||
*Check information about the cursor's location in the tree* | ||
*Checking information about the cursor's location in the tree* | ||
@@ -605,4 +551,3 @@ ```js | ||
{ | ||
maxHistory: 5, | ||
clone: true | ||
autoCommit: false | ||
} | ||
@@ -614,14 +559,110 @@ ) | ||
* **asynchronous** *boolean* [`true`]: should the tree delay the update to the next frame or fire them synchronously? | ||
* **clone** *boolean* [`false`]: by default, the tree will give access to references. Set to `true` to clone data when retrieving it from the tree if you feel paranoid and know you might mutate the references by accident or need a cloned object to handle. | ||
* **cloningFunction** *function*: the library's cloning method is minimalist on purpose and won't cover edgy cases. You remain free to pass your own more complex cloning function to the tree if needed. | ||
* **cursorSingletons** *boolean* [`true`]: by default, a *baobab* tree stashes the created cursor so only one would be created by path. You can override this behaviour by setting `cursorSingletons` to `false`. | ||
* **maxHistory** *number* [`0`]: max number of records the tree is allowed to store within its internal history. | ||
* **mixins** *array*: optional mixins to merge with baobab's ones. Recommending the [pure render](http://facebook.github.io/react/docs/pure-render-mixin.html) one from react. | ||
* **shiftReferences** *boolean* [`false`]: tell the tree to shift references of the objects it updates so that functions performing shallow comparisons (such as the one used by the `PureRenderMixin`, for instance), can assess that data changed. | ||
* **typology** *Typology|object*: a custom typology to be used to validate the tree's data. | ||
* **validate** *object*: a [typology](https://github.com/jacomyal/typology) schema ensuring the tree's data is valid. | ||
* **facets** *object*: a collection of facets to register when the tree is istantiated. For more information, see [facets](#facets). | ||
* **validate** *function*: a function in charge of validating the tree whenever it updates. See below for an example of such function. | ||
* **validationBehavior** *string* [`rollback`]: validation behavior of the tree. If `rollback`, the tree won't apply the current update and fire an `invalid` event while `notify` will only emit the event and let the tree enter the invalid state anyway. | ||
*Validation function* | ||
```js | ||
function validationFunction(previousState, newState, affectedPaths) { | ||
// Peform validation here and return an error if | ||
// the tree is invalid | ||
if (!valid) | ||
return new Error('Invalid tree because of reasons.'); | ||
} | ||
var tree = new Baobab({...}, {validate: validationFunction}); | ||
``` | ||
#### Facets | ||
Facets can be considered as a "view" on the data of your tree (a filtered version of an array stored within your tree, for instance). | ||
They watch over some paths of your tree and will update their cached data only when needed. As for cursors, you can also listen to their updates. | ||
Facets can be defined at the tree's instantiation likewise: | ||
```js | ||
var tree = new Baobab( | ||
// Data | ||
{ | ||
projects: [ | ||
{ | ||
id: 1, | ||
name: 'Tezcatlipoca', | ||
user: 'John' | ||
}, | ||
{ | ||
id: 2, | ||
name: 'Huitzilopochtli', | ||
user: 'John' | ||
}, | ||
{ | ||
id: 3, | ||
name: 'Tlaloc', | ||
user: 'Jack' | ||
} | ||
], | ||
currentProjectId: 1 | ||
}, | ||
// Options | ||
{ | ||
facets: { | ||
// Name of your facet | ||
currentProject: { | ||
// Cursors bound to your facet | ||
// If any of the paths listed below fire | ||
// an update, so will the facet. | ||
cursors: { | ||
id: ['currentProjectId'], | ||
projects: ['projects'] | ||
}, | ||
get: function(data) { | ||
// 'data' is the value of your mapped cursors | ||
// Just return the wanted value | ||
// Here, we use lodash to return the current's project | ||
// data based on its id | ||
return _.find(data.projects, {id: data.id}); | ||
} | ||
}, | ||
// Other example | ||
filteredProjects: { | ||
cursors: { | ||
projects: ['projects'] | ||
}, | ||
get: function(data) { | ||
return data.projects.filter(function(p) { | ||
return p.user === 'John'; | ||
}); | ||
} | ||
}, | ||
} | ||
} | ||
) | ||
``` | ||
You can then access facets' data and listen to their changes thusly: | ||
```js | ||
var facet = tree.facets.currentProject; | ||
// Getting value (cached and only computed if needed) | ||
facet.get(); | ||
// Listening | ||
facet.on('update', function() { | ||
console.log('New value:', facet.get()); | ||
}); | ||
``` | ||
#### History | ||
A *baobab* tree, given you instantiate it with the correct option, is able to record *n* of its passed states so you can go back in time whenever you want. | ||
**Baobab** lets you record the state of any cursor so you can seamlessly implement undo/redo features. | ||
@@ -631,32 +672,77 @@ *Example* | ||
```js | ||
var baobab = new Baobab({name: 'Maria'}, {maxHistory: 1}); | ||
// Asynchronous tree so that examples are simpler | ||
var baobab = new Baobab({colors: ['blue']}, {asynchronous: false}), | ||
cursor = baobab.select('colors'); | ||
baobab.set('name', 'Isabella'); | ||
// Starting to record state, with 10 records maximum | ||
cursor.startRecording(10); | ||
// On next frame, when update has been committed | ||
baobab.get('name') | ||
>>> 'Isabella' | ||
baobab.undo(); | ||
baobab.get('name') | ||
>>> 'Maria' | ||
cursor.push('yellow'); | ||
cursor.push('purple'); | ||
cursor.push('orange'); | ||
cursor.get(); | ||
>>> ['blue', 'yellow', 'purple', 'orange'] | ||
cursor.undo(); | ||
cursor.get(); | ||
>>> ['blue', 'yellow', 'purple'] | ||
cursor.undo(2); | ||
cursor.get(); | ||
>>> ['blue'] | ||
``` | ||
*Related Methods* | ||
*Starting recording* | ||
Default max number of records is 5. | ||
```js | ||
// Check whether our tree hold records | ||
baobab.hasHistory(); | ||
>>> true | ||
cursor.startRecording(maxNbOfRecords); | ||
``` | ||
// Retrieving history records | ||
baobab.getHistory(); | ||
*Stoping recording* | ||
```js | ||
cursor.stopRecording(); | ||
``` | ||
*Undoing* | ||
```js | ||
cursor.undo(); | ||
cursor.undo(nbOfSteps); | ||
``` | ||
*Clearing history* | ||
```js | ||
cursor.clearHistory(); | ||
``` | ||
*Checking if the cursor has an history* | ||
```js | ||
cursor.hasHistory(); | ||
``` | ||
*Checking whether the cursor is currently recording* | ||
```js | ||
cursor.recording; | ||
``` | ||
*Retrieving the cursor's history* | ||
```js | ||
cursor.getHistory(); | ||
``` | ||
#### Update specifications | ||
If you ever need to specify complex updates without resetting the whole subtree you are acting on, for readability or performance reasons, you remain free to use **Baobab**'s internal update specifications. | ||
If you ever need to specify complex updates without replacing the whole subtree you are acting on, for readability or performance reasons, you remain free to use **Baobab**'s internal update specifications. | ||
Those are widely inspired by React's immutable [helpers](http://facebook.github.io/react/docs/update.html), themselves inspired by [MongoDB](http://www.mongodb.org/)'s ones and can be used through `tree.update` and `cursor.update`. | ||
Those are widely inspired by React's immutable [helpers](http://facebook.github.io/react/docs/update.html) and can be used through `tree.update` or `cursor.update`. | ||
*Specifications* | ||
**Specifications** | ||
@@ -672,2 +758,3 @@ Those specifications are described by a JavaScript object that follows the nested structure you are trying to update and applying dollar-prefixed commands at leaf level. | ||
* `$unshift` | ||
* `$splice` | ||
* `$merge` | ||
@@ -739,115 +826,52 @@ * `$unset` | ||
#### Cursor combinations | ||
#### Common pitfalls | ||
At times, you might want to listen to updates concerning a logical combination of cursors. For instance, you might want to know when two cursors both updated or when either one or the other did. | ||
*Immutable behaviour* | ||
You can build cursor combination likewise: | ||
TL;DR: Don't mutate things in your *baobab* tree. Let the tree handle its own mutations. | ||
```js | ||
// Simple "or" combination | ||
var combination = cursor1.or(cursor2); | ||
For performance and size reasons *baobab* does not (yet?) use an immutable data structure. However, because it aims at producing a one-way data flow for your application state (like **React** would at component level), it must be used like an immutable data structure. | ||
// Simple "and" combination | ||
var combination = cursor1.and(cursor2); | ||
For this reason, don't be surprised if you mutate things and break your tree. | ||
// Complex combination | ||
var combination = cursor1.or(cursor2).or(cursor3).and(cursor4); | ||
// Listening to events | ||
combination.on('update', handler); | ||
// Releasing a combination to avoid leaks | ||
combination.release(); | ||
``` | ||
#### Data validation | ||
Given you pass the correct parameters, a baobab tree is able to check whether its data is valid or not against the supplied specification. | ||
This specification must be written in the [typology](https://github.com/jacomyal/typology) library's style. | ||
*Example* | ||
```js | ||
var baobab = new Baobab( | ||
// This is bad: | ||
var users = tree.get('users'); | ||
users[0].name = 'Jonathan'; | ||
// Initial state | ||
{ | ||
hello: 'world', | ||
colors: ['yellow', 'blue'], | ||
counters: { | ||
users: 3, | ||
groups: 1 | ||
} | ||
}, | ||
// Parameters | ||
{ | ||
validate: { | ||
hello: '?string', | ||
colors: ['string'], | ||
counters: { | ||
users: 'number', | ||
groups: 'number' | ||
} | ||
} | ||
} | ||
); | ||
// If one updates the tree and does not respect the validation specification | ||
baobab.set('hello', 42); | ||
// Then the tree will fire an 'invalid' event containing a list of errors | ||
baobab.on('invalid', function(e) { | ||
console.log(e.data.errors); | ||
}); | ||
// This is also bad: | ||
var o = {hello: 'world'}; | ||
tree.set('key', o); | ||
o.hello = 'other world'; | ||
``` | ||
#### Common pitfalls | ||
## Philosophy | ||
*Controlled input state* | ||
**UIs as pure functions** | ||
If you need to store a react controlled input's state into a baobab tree, remember you have to commit changes synchronously through the `commit` method if you don't want to observe nasty cursor jumps. | ||
UIs should be, as far as possible, considered as pure functions. Baobab is just a way to provide the needed arguments, i.e. the data representing your app's state, to such a function. | ||
```jsx | ||
var tree = new Boabab({inputValue: null}); | ||
Considering your UIs like pure functions comes along with collateral advantages like easy undo/redo features, state storing (just save your tree in the `localStorage` and here you go) and easy isomorphism. | ||
var Input = React.createClass({ | ||
mixins: [tree.mixin], | ||
cursor: ['inputValue'], | ||
onChange: function(e) { | ||
var newValue = e.target.value; | ||
**Only data should enter the tree** | ||
// If one edits the tree normally, i.e. asynchronously, the cursor will hop | ||
this.cursor.edit(newValue); | ||
You shouldn't try to shove anything else than raw data into the tree. The tree hasn't been conceived to hold classes or fancy indexes with many circular references and cannot perform its magic on it. But, probably such magic is not desirable for those kind of abstractions anyway. | ||
// One has to commit synchronously the update for the input to work correctly | ||
this.cursor.edit(newValue); | ||
this.tree.commit(); | ||
}, | ||
render: function() { | ||
return <input onChange={this.onChange} value={this.cursor.get()} />; | ||
} | ||
}); | ||
``` | ||
That is to say the data you insert into the tree should logically be JSON-serializable else you might be missing the point. | ||
*Immutable behaviour* | ||
## Migration | ||
TL;DR: Don't mutate things in your *baobab* tree. Let the tree handle its own mutations. | ||
**From v0.4.x to 1.0.0** | ||
For performance and size reasons *baobab* does not (yet?) use an immutable data structure. However, because it aims at producing a one-way data flow for your application state (like **React** would at component level), it must be used like an immutable data structure. | ||
A lot of changes occurred between `0.4.x` and `1.0.0`. Most notable changes being the following ones: | ||
For this reason, don't be surprised if you mutate things and break your tree. | ||
* The tree now shift references by default. | ||
* React integration has improved and is now handled by [baobab-react](https://github.com/Yomguithereal/baobab-react). | ||
* `cursor.edit` and `cursor.remove` have been replaced by `cursor.set` and `cursor.unset` single argument polymorphisms. | ||
* A lot of options (now unnecessary) have been dropped. | ||
* Validation is no longer handled by [`typology`](https://github.com/jacomyal/typology) so you can choose you own validation system and so the library can remain lighter. | ||
* Some new features such as: `$splice`, facets and so on... | ||
```js | ||
// This is bad: | ||
var users = tree.get('users'); | ||
users[0].name = 'Jonathan'; | ||
For more information, see the [changelog](./CHANGELOG.md). | ||
// This is also bad: | ||
var o = {hello: 'world'}; | ||
tree.set('key', o); | ||
o.hello = 'other world'; | ||
``` | ||
## Contribution | ||
@@ -854,0 +878,0 @@ |
@@ -9,7 +9,7 @@ /** | ||
EventEmitter = require('emmett'), | ||
Typology = require('typology'), | ||
Watcher = require('./watcher.js'), | ||
Facet = require('./facet.js'), | ||
helpers = require('./helpers.js'), | ||
update = require('./update.js'), | ||
merge = require('./merge.js'), | ||
mixins = require('./mixins.js'), | ||
defaults = require('../defaults.js'), | ||
@@ -42,3 +42,2 @@ type = require('./type.js'); | ||
this.options = helpers.shallowMerge(defaults, opts); | ||
this._cloner = this.options.cloningFunction || helpers.deepClone; | ||
@@ -48,29 +47,26 @@ // Privates | ||
this._future = undefined; | ||
this._history = []; | ||
this._cursors = {}; | ||
this._identity = '[object Baobab]'; | ||
// Internal typology | ||
this.typology = this.options.typology ? | ||
(this.options.typology instanceof Typology ? | ||
this.options.typology : | ||
new Typology(this.options.typology)) : | ||
new Typology(); | ||
// Properties | ||
this.data = helpers.deepClone(initialData); | ||
this.root = this.select([]); | ||
this.facets = {}; | ||
// Internal validation | ||
this.validate = this.options.validate || null; | ||
// Boostrapping root cursor's methods | ||
function bootstrap(name) { | ||
this[name] = function() { | ||
var r = this.root[name].apply(this.root, arguments); | ||
return r instanceof Cursor ? this : r; | ||
}; | ||
} | ||
if (this.validate) | ||
try { | ||
this.typology.check(initialData, this.validate, true); | ||
} | ||
catch (e) { | ||
e.message = '/' + e.path.join('/') + ': ' + e.message; | ||
throw e; | ||
} | ||
['get', 'set', 'unset', 'update'].forEach(bootstrap.bind(this)); | ||
// Properties | ||
this.data = this._cloner(initialData); | ||
// Facets | ||
if (!type.Object(this.options.facets)) | ||
throw Error('Baobab: invalid facets.'); | ||
// Mixin | ||
this.mixin = mixins.baobab(this); | ||
for (var k in this.options.facets) | ||
this.addFacet(k, this.options.facets[k]); | ||
} | ||
@@ -81,87 +77,13 @@ | ||
/** | ||
* Private prototype | ||
*/ | ||
Baobab.prototype._archive = function() { | ||
if (this.options.maxHistory <= 0) | ||
return; | ||
var record = { | ||
data: this._cloner(this.data) | ||
}; | ||
// Replacing | ||
if (this._history.length === this.options.maxHistory) { | ||
this._history.pop(); | ||
} | ||
this._history.unshift(record); | ||
return record; | ||
}; | ||
/** | ||
* Prototype | ||
*/ | ||
Baobab.prototype.commit = function(referenceRecord) { | ||
var self = this, | ||
log; | ||
if (referenceRecord) { | ||
// Override | ||
this.data = referenceRecord.data; | ||
log = referenceRecord.log; | ||
} | ||
else { | ||
// Shifting root reference | ||
if (this.options.shiftReferences) | ||
this.data = helpers.shallowClone(this.data); | ||
// Applying modification (mutation) | ||
var record = this._archive(); | ||
log = update(this.data, this._transaction, this.options); | ||
if (record) | ||
record.log = log; | ||
} | ||
if (this.validate) { | ||
var errors = [], | ||
l = log.length, | ||
d, | ||
i; | ||
for (i = 0; i < l; i++) { | ||
d = helpers.getIn(this.validate, log[i]); | ||
if (!d) | ||
continue; | ||
try { | ||
this.typology.check(this.get(log[i]), d, true); | ||
} | ||
catch (e) { | ||
e.path = log[i].concat((e.path || [])); | ||
errors.push(e); | ||
} | ||
} | ||
if (errors.length) | ||
this.emit('invalid', {errors: errors}); | ||
} | ||
// Resetting | ||
this._transaction = {}; | ||
if (this._future) | ||
this._future = clearTimeout(this._future); | ||
// Baobab-level update event | ||
this.emit('update', { | ||
log: log | ||
}); | ||
Baobab.prototype.addFacet = function(name, definition) { | ||
this.facets[name] = this.createFacet(definition); | ||
return this; | ||
}; | ||
Baobab.prototype.createFacet = function(definition) { | ||
return new Facet(this, definition); | ||
}; | ||
Baobab.prototype.select = function(path) { | ||
@@ -178,3 +100,3 @@ if (!path) | ||
// Casting to array | ||
path = !type.Array(path) ? [path] : path; | ||
path = [].concat(path); | ||
@@ -187,90 +109,25 @@ // Complex path? | ||
if (complex) | ||
solvedPath = helpers.solvePath(this.data, path); | ||
solvedPath = helpers.solvePath(this.data, path, this); | ||
// Registering a new cursor or giving the already existing one for path | ||
if (!this.options.cursorSingletons) { | ||
return new Cursor(this, path); | ||
} | ||
else { | ||
var hash = path.map(function(step) { | ||
if (type.Function(step)) | ||
return complexHash('fn'); | ||
else if (type.Object(step)) | ||
return complexHash('ob'); | ||
else | ||
return step; | ||
}).join('λ'); | ||
var hash = path.map(function(step) { | ||
if (type.Function(step)) | ||
return complexHash('fn'); | ||
else if (type.Object(step)) | ||
return complexHash('ob'); | ||
else | ||
return step; | ||
}).join('|λ|'); | ||
if (!this._cursors[hash]) { | ||
var cursor = new Cursor(this, path, solvedPath, hash); | ||
this._cursors[hash] = cursor; | ||
return cursor; | ||
} | ||
else { | ||
return this._cursors[hash]; | ||
} | ||
if (!this._cursors[hash]) { | ||
var cursor = new Cursor(this, path, solvedPath, hash); | ||
this._cursors[hash] = cursor; | ||
return cursor; | ||
} | ||
}; | ||
Baobab.prototype.root = function() { | ||
return this.select([]); | ||
}; | ||
Baobab.prototype.reference = function(path) { | ||
var data; | ||
if (arguments.length > 1) | ||
path = helpers.arrayOf(arguments); | ||
if (!type.Path(path)) | ||
throw Error('Baobab.get: invalid path.'); | ||
return helpers.getIn( | ||
this.data, type.String(path) || type.Number(path) ? [path] : path | ||
); | ||
}; | ||
Baobab.prototype.get = function() { | ||
var ref = this.reference.apply(this, arguments); | ||
return this.options.clone ? this._cloner(ref) : ref; | ||
}; | ||
Baobab.prototype.clone = function(path) { | ||
return this._cloner(this.reference.apply(this, arguments)); | ||
}; | ||
Baobab.prototype.set = function(key, val) { | ||
if (arguments.length < 2) | ||
throw Error('Baobab.set: expects a key and a value.'); | ||
var spec = {}; | ||
if (type.Array(key)) { | ||
var path = helpers.solvePath(this.data, key); | ||
if (!path) | ||
throw Error('Baobab.set: could not solve dynamic path.'); | ||
spec = helpers.pathObject(path, {$set: val}); | ||
} | ||
else { | ||
spec[key] = {$set: val}; | ||
return this._cursors[hash]; | ||
} | ||
return this.update(spec); | ||
}; | ||
Baobab.prototype.unset = function(key) { | ||
if (!key && key !== 0) | ||
throw Error('Baobab.unset: expects a valid key to unset.'); | ||
var spec = {}; | ||
spec[key] = {$unset: true}; | ||
return this.update(spec); | ||
}; | ||
Baobab.prototype.update = function(spec) { | ||
Baobab.prototype.stack = function(spec) { | ||
var self = this; | ||
@@ -298,29 +155,69 @@ | ||
Baobab.prototype.hasHistory = function() { | ||
return !!this._history.length; | ||
}; | ||
Baobab.prototype.commit = function() { | ||
var self = this; | ||
Baobab.prototype.getHistory = function() { | ||
return this._history; | ||
// Applying modifications | ||
var result = update(this.data, this._transaction, this.options); | ||
var oldData = this.data; | ||
// Resetting | ||
this._transaction = {}; | ||
if (this._future) | ||
this._future = clearTimeout(this._future); | ||
// Validate? | ||
var validate = this.options.validate, | ||
behavior = this.options.validationBehavior; | ||
if (typeof validate === 'function') { | ||
var error = validate.call(this, oldData, result.data, result.log); | ||
if (error instanceof Error) { | ||
this.emit('invalid', {error: error}); | ||
if (behavior === 'rollback') | ||
return this; | ||
} | ||
} | ||
// Switching tree's data | ||
this.data = result.data; | ||
// Baobab-level update event | ||
this.emit('update', { | ||
log: result.log, | ||
previousState: oldData | ||
}); | ||
return this; | ||
}; | ||
Baobab.prototype.undo = function() { | ||
if (!this.hasHistory()) | ||
throw Error('Baobab.undo: no history recorded, cannot undo.'); | ||
Baobab.prototype.watch = function(paths) { | ||
if (!type.Array(paths) || | ||
paths.some(function(p) { | ||
return !type.Path(p); | ||
})) | ||
throw Error('Baobab.watch: invalid paths.'); | ||
var lastRecord = this._history.shift(); | ||
this.commit(lastRecord); | ||
return new Watcher(this, [].concat(paths)); | ||
}; | ||
Baobab.prototype.release = function() { | ||
var k; | ||
delete this.data; | ||
delete this._transaction; | ||
delete this._history; | ||
// Releasing cursors | ||
for (var k in this._cursors) | ||
for (k in this._cursors) | ||
this._cursors[k].release(); | ||
delete this._cursors; | ||
// Releasing facets | ||
for (k in this.facets) | ||
this.facets[k].release(); | ||
delete this.facets; | ||
// Killing event emitter | ||
@@ -334,5 +231,9 @@ this.kill(); | ||
Baobab.prototype.toJSON = function() { | ||
return this.reference(); | ||
return this.get(); | ||
}; | ||
Baobab.prototype.toString = function() { | ||
return this._identity; | ||
}; | ||
/** | ||
@@ -339,0 +240,0 @@ * Export |
@@ -8,5 +8,4 @@ /** | ||
var EventEmitter = require('emmett'), | ||
Combination = require('./combination.js'), | ||
mixins = require('./mixins.js'), | ||
helpers = require('./helpers.js'), | ||
defaults = require('../defaults.js'), | ||
type = require('./type.js'); | ||
@@ -30,4 +29,9 @@ | ||
this.hash = hash; | ||
this.relevant = this.reference() !== undefined; | ||
this.archive = null; | ||
this.recording = false; | ||
this.undoing = false; | ||
// Privates | ||
this._identity = '[object Cursor]'; | ||
// Complex path? | ||
@@ -37,5 +41,23 @@ this.complexPath = !!solvedPath; | ||
// Relevant? | ||
this.relevant = this.get() !== undefined; | ||
// Root listeners | ||
function update(previousState) { | ||
if (self.recording && !self.undoing) { | ||
// Handle archive | ||
var data = helpers.getIn(previousState, self.solvedPath, self.tree), | ||
record = helpers.deepClone(data); | ||
self.archive.add(record); | ||
} | ||
self.undoing = false; | ||
return self.emit('update'); | ||
} | ||
this.updateHandler = function(e) { | ||
var log = e.data.log, | ||
previousState = e.data.previousState, | ||
shouldFire = false, | ||
@@ -46,34 +68,18 @@ c, p, l, m, i, j; | ||
if (self.complexPath) | ||
self.solvedPath = helpers.solvePath(self.tree.data, self.path); | ||
self.solvedPath = helpers.solvePath(self.tree.data, self.path, self.tree); | ||
// If selector listens at tree, we fire | ||
if (!self.path.length) | ||
return self.emit('update'); | ||
return update(previousState); | ||
// Checking update log to see whether the cursor should update. | ||
outer: | ||
for (i = 0, l = log.length; i < l; i++) { | ||
c = log[i]; | ||
if (self.solvedPath) | ||
shouldFire = helpers.solveUpdate(log, [self.solvedPath]); | ||
for (j = 0, m = c.length; j < m; j++) { | ||
p = c[j]; | ||
// If path is not relevant to us, we break | ||
if (p !== '' + self.solvedPath[j]) | ||
break; | ||
// If we reached last item and we are relevant, we fire | ||
if (j + 1 === m || j + 1 === self.solvedPath.length) { | ||
shouldFire = true; | ||
break outer; | ||
} | ||
} | ||
} | ||
// Handling relevancy | ||
var data = self.reference() !== undefined; | ||
var data = self.get() !== undefined; | ||
if (self.relevant) { | ||
if (data && shouldFire) { | ||
self.emit('update'); | ||
update(previousState); | ||
} | ||
@@ -88,3 +94,3 @@ else if (!data) { | ||
self.emit('relevant'); | ||
self.emit('update'); | ||
update(previousState); | ||
self.relevant = true; | ||
@@ -95,11 +101,6 @@ } | ||
// Making mixin | ||
this.mixin = mixins.cursor(this); | ||
// Lazy binding | ||
var bound = false, | ||
regularOn = this.on, | ||
regularOnce = this.once; | ||
var bound = false; | ||
var lazyBind = function() { | ||
this._lazyBind = function() { | ||
if (bound) | ||
@@ -111,11 +112,7 @@ return; | ||
this.on = function() { | ||
lazyBind(); | ||
return regularOn.apply(this, arguments); | ||
}; | ||
this.on = helpers.before(this._lazyBind, this.on.bind(this)); | ||
this.once = helpers.before(this._lazyBind, this.once.bind(this)); | ||
this.once = function() { | ||
lazyBind(); | ||
return regularOnce.apply(this, arguments); | ||
}; | ||
if (this.complexPath) | ||
this._lazyBind(); | ||
} | ||
@@ -133,3 +130,3 @@ | ||
Cursor.prototype.isLeaf = function() { | ||
return type.Primitive(this.reference()); | ||
return type.Primitive(this.get()); | ||
}; | ||
@@ -190,3 +187,3 @@ | ||
if (last + 1 === this.up().reference().length) | ||
if (last + 1 === this.up().get().length) | ||
return null; | ||
@@ -203,3 +200,3 @@ | ||
var list = this.up().reference(); | ||
var list = this.up().get(); | ||
@@ -212,3 +209,3 @@ return this.tree.select(this.solvedPath.slice(0, -1).concat(list.length - 1)); | ||
if (!(this.reference() instanceof Array)) | ||
if (!(this.get() instanceof Array)) | ||
return null; | ||
@@ -226,138 +223,146 @@ | ||
if (type.Step(path)) | ||
return this.tree.get(this.solvedPath.concat(path)); | ||
else | ||
return this.tree.get(this.solvedPath); | ||
}; | ||
var fullPath = this.solvedPath.concat( | ||
[].concat(path || path === 0 ? path : []) | ||
); | ||
Cursor.prototype.reference = function(path) { | ||
if (arguments.length > 1) | ||
path = helpers.arrayOf(arguments); | ||
if (type.Step(path)) | ||
return this.tree.reference(this.solvedPath.concat(path)); | ||
else | ||
return this.tree.reference(this.solvedPath); | ||
return helpers.getIn(this.tree.data, fullPath, this.tree); | ||
}; | ||
Cursor.prototype.clone = function(path) { | ||
if (arguments.length > 1) | ||
path = helpers.arrayOf(arguments); | ||
if (type.Step(path)) | ||
return this.tree.clone(this.solvedPath.concat(path)); | ||
else | ||
return this.tree.clone(this.solvedPath); | ||
}; | ||
/** | ||
* Update | ||
*/ | ||
Cursor.prototype.set = function(key, val) { | ||
if (arguments.length < 2) | ||
throw Error('baobab.Cursor.set: expecting at least key/value.'); | ||
function pathPolymorphism(method, allowedType, key, val) { | ||
if (arguments.length > 5) | ||
throw Error('baobab.Cursor.' + method + ': too many arguments.'); | ||
var data = this.reference(); | ||
if (arguments.length < 4) { | ||
val = key; | ||
key = []; | ||
} | ||
if (typeof data !== 'object') | ||
throw Error('baobab.Cursor.set: trying to set key to a non-object.'); | ||
key = key || []; | ||
var spec = {}; | ||
// Splice exception | ||
if (method === 'splice' && | ||
!type.Splicer(val)) { | ||
if (type.Array(val)) | ||
val = [val]; | ||
else | ||
throw Error('baobab.Cursor.splice: incorrect value.'); | ||
} | ||
if (type.Array(key)) { | ||
var path = helpers.solvePath(data, key); | ||
// Checking value validity | ||
if (allowedType && !allowedType(val)) | ||
throw Error('baobab.Cursor.' + method + ': incorrect value.'); | ||
if (!path) | ||
throw Error('baobab.Cursor.set: could not solve dynamic path.'); | ||
var path = [].concat(key), | ||
solvedPath = helpers.solvePath(this.get(), path, this.tree); | ||
spec = helpers.pathObject(path, {$set: val}); | ||
} | ||
else { | ||
spec[key] = {$set: val}; | ||
} | ||
if (!solvedPath) | ||
throw Error('baobab.Cursor.' + method + ': could not solve dynamic path.'); | ||
return this.update(spec); | ||
}; | ||
var leaf = {}; | ||
leaf['$' + method] = val; | ||
Cursor.prototype.edit = function(val) { | ||
return this.update({$set: val}); | ||
}; | ||
var spec = helpers.pathObject(solvedPath, leaf); | ||
return spec; | ||
} | ||
function makeUpdateMethod(command, type) { | ||
Cursor.prototype[command] = function() { | ||
var spec = pathPolymorphism.bind(this, command, type).apply(this, arguments); | ||
return this.update(spec); | ||
}; | ||
} | ||
makeUpdateMethod('set'); | ||
makeUpdateMethod('apply', type.Function); | ||
makeUpdateMethod('chain', type.Function); | ||
makeUpdateMethod('push'); | ||
makeUpdateMethod('unshift'); | ||
makeUpdateMethod('merge', type.Object); | ||
makeUpdateMethod('splice'); | ||
Cursor.prototype.unset = function(key) { | ||
if (!key && key !== 0) | ||
throw Error('baobab.Cursor.unset: expects a valid key to unset.'); | ||
if (key === undefined && this.isRoot()) | ||
throw Error('baobab.Cursor.unset: cannot remove root node.'); | ||
if (typeof this.reference() !== 'object') | ||
throw Error('baobab.Cursor.set: trying to set key to a non-object.'); | ||
var spec = pathPolymorphism.bind(this, 'unset', null).apply(this, [key, true]); | ||
var spec = {}; | ||
spec[key] = {$unset: true}; | ||
return this.update(spec); | ||
}; | ||
Cursor.prototype.remove = function() { | ||
if (this.isRoot()) | ||
throw Error('baobab.Cursor.remove: cannot remove root node.'); | ||
Cursor.prototype.update = function(key, spec) { | ||
if (arguments.length < 2) { | ||
this.tree.stack(helpers.pathObject(this.solvedPath, key)); | ||
return this; | ||
} | ||
return this.update({$unset: true}); | ||
}; | ||
// Solving path | ||
var path = [].concat(key), | ||
solvedPath = helpers.solvePath(this.get(), path, this.tree); | ||
Cursor.prototype.apply = function(fn) { | ||
if (typeof fn !== 'function') | ||
throw Error('baobab.Cursor.apply: argument is not a function.'); | ||
if (!solvedPath) | ||
throw Error('baobab.Cursor.update: could not solve dynamic path.'); | ||
return this.update({$apply: fn}); | ||
this.tree.stack(helpers.pathObject(this.solvedPath.concat(solvedPath), spec)); | ||
return this; | ||
}; | ||
Cursor.prototype.chain = function(fn) { | ||
if (typeof fn !== 'function') | ||
throw Error('baobab.Cursor.chain: argument is not a function.'); | ||
/** | ||
* History | ||
*/ | ||
Cursor.prototype.startRecording = function(maxRecords) { | ||
maxRecords = maxRecords || 5; | ||
return this.update({$chain: fn}); | ||
}; | ||
if (maxRecords < 1) | ||
throw Error('baobab.Cursor.startRecording: invalid maximum number of records.'); | ||
Cursor.prototype.push = function(value) { | ||
if (!(this.reference() instanceof Array)) | ||
throw Error('baobab.Cursor.push: trying to push to non-array value.'); | ||
if (this.archive) | ||
return this; | ||
if (arguments.length > 1) | ||
return this.update({$push: helpers.arrayOf(arguments)}); | ||
else | ||
return this.update({$push: value}); | ||
// Lazy bind | ||
this._lazyBind(); | ||
this.archive = helpers.archive(maxRecords); | ||
this.recording = true; | ||
return this; | ||
}; | ||
Cursor.prototype.unshift = function(value) { | ||
if (!(this.reference() instanceof Array)) | ||
throw Error('baobab.Cursor.push: trying to push to non-array value.'); | ||
if (arguments.length > 1) | ||
return this.update({$unshift: helpers.arrayOf(arguments)}); | ||
else | ||
return this.update({$unshift: value}); | ||
Cursor.prototype.stopRecording = function() { | ||
this.recording = false; | ||
return this; | ||
}; | ||
Cursor.prototype.merge = function(o) { | ||
if (!type.Object(o)) | ||
throw Error('baobab.Cursor.merge: trying to merge a non-object.'); | ||
Cursor.prototype.undo = function(steps) { | ||
steps = steps || 1; | ||
if (!type.Object(this.reference())) | ||
throw Error('baobab.Cursor.merge: trying to merge into a non-object.'); | ||
if (!this.recording) | ||
throw Error('baobab.Cursor.undo: cursor is not recording.'); | ||
this.update({$merge: o}); | ||
if (!type.PositiveInteger(steps)) | ||
throw Error('baobab.Cursor.undo: expecting a positive integer.'); | ||
var record = this.archive.back(steps); | ||
if (!record) | ||
throw Error('boabab.Cursor.undo: cannot find a relevant record (' + steps + ' back).'); | ||
this.undoing = true; | ||
return this.set(record); | ||
}; | ||
Cursor.prototype.update = function(spec) { | ||
this.tree.update(helpers.pathObject(this.solvedPath, spec)); | ||
return this; | ||
Cursor.prototype.hasHistory = function() { | ||
return !!(this.archive && this.archive.get().length); | ||
}; | ||
/** | ||
* Combination | ||
*/ | ||
Cursor.prototype.or = function(otherCursor) { | ||
return new Combination('or', this, otherCursor); | ||
Cursor.prototype.getHistory = function() { | ||
return this.archive ? this.archive.get() : []; | ||
}; | ||
Cursor.prototype.and = function(otherCursor) { | ||
return new Combination('and', this, otherCursor); | ||
Cursor.prototype.clearHistory = function() { | ||
this.archive = null; | ||
return this; | ||
}; | ||
@@ -381,2 +386,3 @@ | ||
delete this.solvedPath; | ||
delete this.archive; | ||
@@ -391,7 +397,7 @@ // Killing emitter | ||
Cursor.prototype.toJSON = function() { | ||
return this.reference(); | ||
return this.get(); | ||
}; | ||
type.Cursor = function (value) { | ||
return value instanceof Cursor; | ||
Cursor.prototype.toString = function() { | ||
return this._identity; | ||
}; | ||
@@ -398,0 +404,0 @@ |
@@ -14,2 +14,22 @@ /** | ||
// Decorate a function by applying something before it | ||
function before(decorator, fn) { | ||
return function() { | ||
decorator(); | ||
fn.apply(null, arguments); | ||
}; | ||
} | ||
// Non-mutative splice function | ||
function splice(array, index, nb /*, &elements */) { | ||
var elements = arrayOf(arguments).slice(3); | ||
index = +index; | ||
nb = +nb; | ||
return array | ||
.slice(0, index) | ||
.concat(array.slice(index + nb).concat(elements)); | ||
} | ||
// Shallow merge | ||
@@ -155,6 +175,7 @@ function shallowMerge(o1, o2) { | ||
// Retrieve nested objects | ||
function getIn(object, path) { | ||
function getIn(object, path, tree) { | ||
path = path || []; | ||
var c = object, | ||
p, | ||
i, | ||
@@ -174,6 +195,17 @@ l; | ||
else if (typeof path[i] === 'object') { | ||
if (!type.Array(c)) | ||
if (tree && '$cursor' in path[i]) { | ||
if (!type.Path(path[i].$cursor)) | ||
throw Error('baobab.getIn: $cursor path must be an array.'); | ||
p = tree.get(path[i].$cursor); | ||
c = c[p]; | ||
} | ||
else if (!type.Array(c)) { | ||
return; | ||
} | ||
c = firstByComparison(c, path[i]); | ||
else { | ||
c = firstByComparison(c, path[i]); | ||
} | ||
} | ||
@@ -189,3 +221,3 @@ else { | ||
// Solve a complex path | ||
function solvePath(object, path) { | ||
function solvePath(object, path, tree) { | ||
var solvedPath = [], | ||
@@ -210,8 +242,20 @@ c = object, | ||
else if (typeof path[i] === 'object') { | ||
if (!type.Array(c)) | ||
if (tree && '$cursor' in path[i]) { | ||
if (!type.Path(path[i].$cursor)) | ||
throw Error('baobab.getIn: $cursor path must be an array.'); | ||
p = tree.get(path[i].$cursor); | ||
solvedPath.push(p); | ||
c = c[p]; | ||
} | ||
else if (!type.Array(c)) { | ||
return; | ||
} | ||
idx = indexByComparison(c, path[i]); | ||
solvedPath.push(idx); | ||
c = c[idx]; | ||
else { | ||
idx = indexByComparison(c, path[i]); | ||
solvedPath.push(idx); | ||
c = c[idx]; | ||
} | ||
} | ||
@@ -227,2 +271,36 @@ else { | ||
// Determine whether an update should fire for the given paths | ||
// NOTES: 1) if performance becomes an issue, the threefold loop can be | ||
// simplified to become a complex twofold one. | ||
// 2) a regex version could also work but I am not confident it would be | ||
// faster. | ||
function solveUpdate(log, paths) { | ||
var i, j, k, l, m, n, p, c, s; | ||
// Looping through possible paths | ||
for (i = 0, l = paths.length; i < l; i++) { | ||
p = paths[i]; | ||
// Looping through logged paths | ||
for (j = 0, m = log.length; j < m; j++) { | ||
c = log[j]; | ||
// Looping through steps | ||
for (k = 0, n = c.length; k < n; k++) { | ||
s = c[k]; | ||
// If path is not relevant, we break | ||
if (s != p[k]) | ||
break; | ||
// If we reached last item and we are relevant | ||
if (k + 1 === n || k + 1 === p.length) | ||
return true; | ||
} | ||
} | ||
} | ||
return false; | ||
} | ||
// Return a fake object relative to the given path | ||
@@ -246,2 +324,3 @@ function pathObject(path, spec) { | ||
// Shim used for cross-compatible event emitting extension | ||
function inherits(ctor, superCtor) { | ||
@@ -255,4 +334,30 @@ ctor.super_ = superCtor; | ||
// Archive | ||
function archive(size) { | ||
var records = []; | ||
return { | ||
add: function(record) { | ||
records.unshift(record); | ||
if (records.length > size) | ||
records.length = size; | ||
}, | ||
back: function(steps) { | ||
var record = records[steps - 1]; | ||
if (record) | ||
records = records.slice(steps); | ||
return record; | ||
}, | ||
get: function() { | ||
return records; | ||
} | ||
}; | ||
} | ||
module.exports = { | ||
archive: archive, | ||
arrayOf: arrayOf, | ||
before: before, | ||
deepClone: deepClone, | ||
@@ -265,3 +370,5 @@ shallowClone: shallowClone, | ||
pathObject: pathObject, | ||
solvePath: solvePath | ||
solvePath: solvePath, | ||
solveUpdate: solveUpdate, | ||
splice: splice | ||
}; |
127
src/merge.js
@@ -11,103 +11,60 @@ /** | ||
// Helpers | ||
function hasKey(o, key) { | ||
return key in (o || {}); | ||
} | ||
var COMMANDS = ['$unset', '$set', '$merge', '$apply']; | ||
function conflict(a, b, key) { | ||
return hasKey(a, key) && hasKey(b, key); | ||
// TODO: delete every keys | ||
function only(o, n, keep) { | ||
COMMANDS.forEach(function(c) { | ||
if (keep !== c) | ||
delete o[c]; | ||
}); | ||
o[keep] = n[keep]; | ||
} | ||
// Main function | ||
function merge() { | ||
var res = {}, | ||
current, | ||
next, | ||
l = arguments.length, | ||
i, | ||
k; | ||
// TODO: use a better way than shallow cloning b? | ||
function merge(a, b) { | ||
var o = helpers.shallowClone(b || {}), | ||
k, | ||
i; | ||
for (i = l - 1; i >= 0; i--) { | ||
COMMANDS.forEach(function(c) { | ||
if (a[c]) | ||
only(o, a, c); | ||
}); | ||
// Upper $set/$apply... and conflicts | ||
// When solving conflicts, here is the priority to apply: | ||
// -- 0) $unset | ||
// -- 1) $set | ||
// -- 2) $merge | ||
// -- 3) $apply | ||
// -- 4) $chain | ||
if (arguments[i].$unset) { | ||
delete res.$set; | ||
delete res.$apply; | ||
delete res.$merge; | ||
res.$unset = arguments[i].$unset; | ||
} | ||
else if (arguments[i].$set) { | ||
delete res.$apply; | ||
delete res.$merge; | ||
delete res.$unset; | ||
res.$set = arguments[i].$set; | ||
continue; | ||
} | ||
else if (arguments[i].$merge) { | ||
delete res.$set; | ||
delete res.$apply; | ||
delete res.$unset; | ||
res.$merge = arguments[i].$merge; | ||
continue; | ||
} | ||
else if (arguments[i].$apply){ | ||
delete res.$set; | ||
delete res.$merge; | ||
delete res.$unset; | ||
res.$apply = arguments[i].$apply; | ||
continue; | ||
} | ||
else if (arguments[i].$chain) { | ||
delete res.$set; | ||
delete res.$merge; | ||
delete res.$unset; | ||
if (a.$chain) { | ||
COMMANDS.slice(0, -1).forEach(function(c) { | ||
delete o[c]; | ||
}); | ||
if (res.$apply) | ||
res.$apply = helpers.compose(res.$apply, arguments[i].$chain); | ||
else | ||
res.$apply = arguments[i].$chain; | ||
continue; | ||
} | ||
if (o.$apply) | ||
o.$apply = helpers.compose(o.$apply, a.$chain); | ||
else | ||
o.$apply = a.$chain; | ||
} | ||
for (k in arguments[i]) { | ||
current = res[k]; | ||
next = arguments[i][k]; | ||
if (a.$splice || o.$splice) { | ||
o.$splice = [].concat(o.$splice || []).concat(a.$splice || []); | ||
} | ||
if (current && type.Object(next)) { | ||
if (a.$push || o.$push) { | ||
o.$push = [].concat(o.$push || []).concat(a.$push || []); | ||
} | ||
// $push conflict | ||
if (conflict(current, next, '$push')) { | ||
if (type.Array(current.$push)) | ||
current.$push = current.$push.concat(next.$push); | ||
else | ||
current.$push = [current.$push].concat(next.$push); | ||
} | ||
if (a.$unshift || o.$unshift) { | ||
o.$unshift = [].concat(a.$unshift || []).concat(o.$unshift || []); | ||
} | ||
// $unshift conflict | ||
else if (conflict(current, next, '$unshift')) { | ||
if (type.Array(next.$unshift)) | ||
current.$unshift = next.$unshift.concat(current.$unshift); | ||
else | ||
current.$unshift = [next.$unshift].concat(current.$unshift); | ||
} | ||
for (k in a) { | ||
// No conflict | ||
else { | ||
res[k] = merge(next, current); | ||
} | ||
} | ||
else { | ||
res[k] = next; | ||
} | ||
} | ||
if (type.Object(a[k])) | ||
o[k] = merge(a[k], o[k]); | ||
else if (k[0] !== '$') | ||
o[k] = a[k]; | ||
} | ||
return res; | ||
return o; | ||
} | ||
module.exports = merge; |
120
src/type.js
@@ -10,106 +10,90 @@ /** | ||
*/ | ||
var type = {}; | ||
// Not reusing methods as it will just be an extra | ||
// call on the stack | ||
var type = function (value) { | ||
if (Array.isArray(value)) { | ||
return 'array'; | ||
} else if (typeof value === 'object' && value !== null) { | ||
return 'object'; | ||
} else if (typeof value === 'string') { | ||
return 'string'; | ||
} else if (typeof value === 'number') { | ||
return 'number'; | ||
} else if (typeof value === 'boolean') { | ||
return 'boolean'; | ||
} else if (typeof value === 'function') { | ||
return 'function'; | ||
} else if (value === null) { | ||
return 'null'; | ||
} else if (value === undefined) { | ||
return 'undefined'; | ||
} else if (value instanceof Date) { | ||
return 'date'; | ||
} else { | ||
return 'invalid'; | ||
} | ||
}; | ||
/** | ||
* Helpers | ||
*/ | ||
function anyOf(value, allowed) { | ||
return allowed.some(function(t) { | ||
return type[t](value); | ||
}); | ||
} | ||
type.Array = function (value) { | ||
/** | ||
* Simple types | ||
*/ | ||
type.Array = function(value) { | ||
return Array.isArray(value); | ||
}; | ||
type.Object = function (value) { | ||
return !Array.isArray(value) && typeof value === 'object' && value !== null; | ||
type.Object = function(value) { | ||
return value && | ||
typeof value === 'object' && | ||
!Array.isArray(value) && | ||
!(value instanceof Function); | ||
}; | ||
type.String = function (value) { | ||
type.String = function(value) { | ||
return typeof value === 'string'; | ||
}; | ||
type.Number = function (value) { | ||
type.Number = function(value) { | ||
return typeof value === 'number'; | ||
}; | ||
type.Boolean = function (value) { | ||
return typeof value === 'boolean'; | ||
type.PositiveInteger = function(value) { | ||
return typeof value === 'number' && value > 0 && value % 1 === 0; | ||
}; | ||
type.Function = function (value) { | ||
type.Function = function(value) { | ||
return typeof value === 'function'; | ||
}; | ||
type.Primitive = function (value) { | ||
type.Primitive = function(value) { | ||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; | ||
}; | ||
type.Date = function (value) { | ||
type.Date = function(value) { | ||
return value instanceof Date; | ||
}; | ||
type.Step = function (value) { | ||
var valueType = type(value); | ||
var notValid = ['null', 'undefined', 'invalid', 'date']; | ||
return notValid.indexOf(valueType) === -1; | ||
/** | ||
* Complex types | ||
*/ | ||
type.NonScalar = function(value) { | ||
return type.Object(value) || type.Array(value); | ||
}; | ||
// Should undefined be allowed? | ||
type.Path = function (value) { | ||
var types = ['object', 'string', 'number', 'function', 'undefined']; | ||
type.Splicer = function(value) { | ||
return type.Array(value) && | ||
value.every(type.Array); | ||
}; | ||
type.Path = function(value) { | ||
var allowed = ['String', 'Number', 'Function', 'Object']; | ||
if (type.Array(value)) { | ||
for (var x = 0; x < value.length; x++) { | ||
if (types.indexOf(type(value[x])) === -1) { | ||
return false; | ||
} | ||
} | ||
} else { | ||
return types.indexOf(type(value)) >= 0; | ||
return value.every(function(step) { | ||
return anyOf(step, allowed); | ||
}); | ||
} | ||
return true; | ||
else { | ||
return anyOf(value, allowed); | ||
} | ||
}; | ||
// string|number|array|cursor|function | ||
type.MixinCursor = function (value) { | ||
var allowedValues = ['string', 'number', 'array', 'function']; | ||
return allowedValues.indexOf(type(value)) >= 0 || type.Cursor(value); | ||
type.MixinCursor = function(value) { | ||
return anyOf(value, ['String', 'Number', 'Array', 'Function', 'Cursor']); | ||
}; | ||
// array|object|function | ||
type.MixinCursors = function (value) { | ||
var allowedValues = ['array', 'object', 'function']; | ||
return allowedValues.indexOf(type(value)) >= 0; | ||
type.MixinCursors = function(value) { | ||
return anyOf(value, ['Object', 'Array', 'Function']); | ||
}; | ||
// Already know this is an array | ||
type.ComplexPath = function (value) { | ||
var complexTypes = ['object', 'function']; | ||
for (var x = 0; x < value.length; x++) { | ||
if (complexTypes.indexOf(type(value[x])) >= 0) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
type.ComplexPath = function(value) { | ||
return value.some(function(step) { | ||
return anyOf(step, ['Object', 'Function']); | ||
}); | ||
}; | ||
module.exports = type; |
@@ -11,17 +11,6 @@ /** | ||
var COMMANDS = {}; | ||
[ | ||
'$set', | ||
'$push', | ||
'$unshift', | ||
'$apply', | ||
'$merge' | ||
].forEach(function(c) { | ||
COMMANDS[c] = true; | ||
}); | ||
// Helpers | ||
function makeError(path, message) { | ||
var e = new Error('baobab.update: ' + message + ' at path /' + | ||
path.toString()); | ||
path.slice(1).join('/')); | ||
@@ -32,144 +21,139 @@ e.path = path; | ||
// Core function | ||
function update(target, spec, opts) { | ||
opts = opts || {shiftReferences: false}; | ||
module.exports = function(data, spec, opts) { | ||
opts = opts || {}; | ||
if (!type.Object(data) && !type.Array(data)) | ||
throw Error('baobab.update: invalid target.'); | ||
var log = {}; | ||
// Closure mutating the internal object | ||
(function mutator(o, spec, path, parent) { | ||
path = path || []; | ||
// Shifting root | ||
data = {root: helpers.shallowClone(data)}; | ||
var hash = path.join('λ'), | ||
// Closure performing the updates themselves | ||
var mutator = function(o, spec, path, parent) { | ||
path = path || ['root']; | ||
var hash = path.join('|λ|'), | ||
lastKey = path[path.length - 1], | ||
fn, | ||
h, | ||
k, | ||
v; | ||
for (k in spec) { | ||
if (COMMANDS[k]) { | ||
v = spec[k]; | ||
var leafLevel = Object.keys(spec).some(function(k) { | ||
return !!~['$set', '$push', '$unshift', '$splice', '$unset', '$merge', '$apply'].indexOf(k); | ||
}); | ||
// Logging update | ||
log[hash] = true; | ||
if (leafLevel) { | ||
log[hash] = true; | ||
// TODO: this could be before in the recursion | ||
// Applying | ||
switch (k) { | ||
case '$push': | ||
if (!type.Array(o)) | ||
throw makeError(path, 'using command $push to a non array'); | ||
for (k in spec) { | ||
if (!type.Array(v)) | ||
o.push(v); | ||
else | ||
o.push.apply(o, v); | ||
break; | ||
case '$unshift': | ||
if (!type.Array(o)) | ||
throw makeError(path, 'using command $unshift to a non array'); | ||
// $unset | ||
if (k === '$unset') { | ||
var olderKey = path[path.length - 2]; | ||
if (!type.Array(v)) | ||
o.unshift(v); | ||
else | ||
o.unshift.apply(o, v); | ||
break; | ||
} | ||
} | ||
else { | ||
h = hash ? hash + 'λ' + k : k; | ||
if (!type.Object(parent[olderKey])) | ||
throw makeError(path.slice(0, -1), 'using command $unset on a non-object'); | ||
if ('$unset' in (spec[k] || {})) { | ||
parent[olderKey] = helpers.shallowClone(o); | ||
delete parent[olderKey][lastKey]; | ||
// Logging update | ||
log[h] = true; | ||
if (type.Array(o)) { | ||
if (!opts.shiftReferences) | ||
o.splice(k, 1); | ||
else | ||
parent[path[path.length - 1]] = o.slice(0, +k).concat(o.slice(+k + 1)); | ||
} | ||
else { | ||
delete o[k]; | ||
} | ||
break; | ||
} | ||
else if ('$set' in (spec[k] || {})) { | ||
v = spec[k].$set; | ||
// Logging update | ||
log[h] = true; | ||
o[k] = v; | ||
// $set | ||
if (k === '$set') { | ||
v = spec.$set; | ||
o[lastKey] = v; | ||
break; | ||
} | ||
else if ('$apply' in (spec[k] || {}) || '$chain' in (spec[k] || {})) { | ||
// TODO: this should not happen likewise. | ||
fn = spec[k].$apply || spec[k].$chain; | ||
// $apply | ||
if (k === '$apply') { | ||
fn = spec.$apply; | ||
if (typeof fn !== 'function') | ||
throw makeError(path.concat(k), 'using command $apply with a non function'); | ||
throw makeError(path, 'using command $apply with a non function'); | ||
// Logging update | ||
log[h] = true; | ||
o[k] = fn.call(null, o[k]); | ||
o[lastKey] = fn.call(null, o[lastKey]); | ||
break; | ||
} | ||
else if ('$merge' in (spec[k] || {})) { | ||
v = spec[k].$merge; | ||
if (!type.Object(o[k])) | ||
throw makeError(path.concat(k), 'using command $merge on a non-object'); | ||
// $merge | ||
if (k === '$merge') { | ||
v = spec.$merge; | ||
// Logging update | ||
log[h] = true; | ||
o[k] = helpers.shallowMerge(o[k], v); | ||
if (!type.Object(o[lastKey]) || !type.Object(v)) | ||
throw makeError(path, 'using command $merge with a non object'); | ||
o[lastKey] = helpers.shallowMerge(o[lastKey], v); | ||
break; | ||
} | ||
else if (opts.shiftReferences && | ||
('$push' in (spec[k] || {}) || | ||
'$unshift' in (spec[k] || {}))) { | ||
if ('$push' in (spec[k] || {})) { | ||
v = spec[k].$push; | ||
if (!type.Array(o[k])) | ||
throw makeError(path.concat(k), 'using command $push to a non array'); | ||
o[k] = o[k].concat(v); | ||
} | ||
if ('$unshift' in (spec[k] || {})) { | ||
v = spec[k].$unshift; | ||
// $splice | ||
if (k === '$splice') { | ||
v = spec.$splice; | ||
if (!type.Array(o[k])) | ||
throw makeError(path.concat(k), 'using command $unshift to a non array'); | ||
o[k] = (v instanceof Array ? v : [v]).concat(o[k]); | ||
} | ||
if (!type.Array(o[lastKey])) | ||
throw makeError(path, 'using command $push to a non array'); | ||
// Logging update | ||
log[h] = true; | ||
v.forEach(function(args) { | ||
o[lastKey] = helpers.splice.apply(null, [o[lastKey]].concat(args)); | ||
}); | ||
} | ||
else { | ||
// If nested object does not exist, we create it | ||
if (typeof o[k] === 'undefined') | ||
o[k] = {}; | ||
// $push | ||
if (k === '$push') { | ||
v = spec.$push; | ||
// Shifting reference | ||
if (opts.shiftReferences) | ||
o[k] = helpers.shallowClone(o[k]); | ||
if (!type.Array(o[lastKey])) | ||
throw makeError(path, 'using command $push to a non array'); | ||
// Recur | ||
// TODO: fix this horrendous behaviour. | ||
mutator( | ||
o[k], | ||
spec[k], | ||
path.concat(k), | ||
o | ||
); | ||
o[lastKey] = o[lastKey].concat(v); | ||
} | ||
// $unshift | ||
if (k === '$unshift') { | ||
v = spec.$unshift; | ||
if (!type.Array(o[lastKey])) | ||
throw makeError(path, 'using command $unshift to a non array'); | ||
o[lastKey] = [].concat(v).concat(o[lastKey]); | ||
} | ||
} | ||
} | ||
})(target, spec); | ||
else { | ||
for (k in spec) { | ||
return Object.keys(log).map(function(hash) { | ||
return hash.split('λ'); | ||
}); | ||
} | ||
// If nested object does not exist, we create it | ||
if (typeof o[lastKey][k] === 'undefined') | ||
o[lastKey][k] = {}; | ||
else | ||
o[lastKey][k] = helpers.shallowClone(o[lastKey][k]); | ||
// Exporting | ||
module.exports = update; | ||
// Recur | ||
mutator( | ||
o[lastKey], | ||
spec[k], | ||
path.concat(k), | ||
o | ||
); | ||
} | ||
} | ||
}; | ||
mutator(data, spec); | ||
// Returning data and path log | ||
return { | ||
data: data.root, | ||
// SHIFT LOG | ||
log: Object.keys(log).map(function(hash) { | ||
return hash.split('|λ|').slice(1); | ||
}) | ||
}; | ||
}; |
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
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
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
1
12
15
870
56600
1186
1
+ Addedemmett@3.2.0(transitive)
- Removedtypology@^0.3.1
- Removedemmett@2.1.2(transitive)
- Removedtypology@0.3.1(transitive)
Updatedemmett@^3.0.0