├── .gitignore ├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── package.json ├── src ├── references.ts ├── stateTree.ts └── utils.ts ├── tests ├── cleaning.js ├── concat.js ├── import.js ├── instantiate.js ├── merge.js ├── pop.js ├── push.js ├── set.js ├── shift.js ├── splice.js ├── unset.js └── unshift.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Christian Alfoni 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # state-tree 2 | A state tree that handles reference updates and lets you flush a description of changes 3 | 4 | ### Why? 5 | There are different ways of handling state. You have libraries like [Baobab](https://github.com/Yomguithereal/baobab) and [Mobx](https://github.com/mobxjs/mobx). They are part of the same domain, but they handle state changes very differently. From experience this is what I want: 6 | 7 | - **One state tree**. It just keeps a much clearer mental image and I never get into circular dependencies with my state models. It is also easier to hydrate and dehydrate the state of my app 8 | 9 | - **Control changes**. I want a simple way to control the changes made to the app. By using a `state.set('some.state', 'foo')` API instead of `some.state = 'foo'` this control becomes more intuitive as you have a specific API for making changes, rather than "changing stuff all over". It also makes it a lot easier to implement tracking of any changes 10 | 11 | - **Fast updates**. Immutability has benefits like being able to replay state changes, undo/redo very easily and no unwanted mutations in other parts of your code. The problem though is that immutability is slow on instantiating large datasets 12 | 13 | - **Referencing**. Immutability breaks referencing. Meaning that if one object references an other object and that object changes, the other object is not updated. This is a good thing from one perspective, but when it comes to handling relational data it is problematic. You have to create normalizing abstractions which can be hard to reason about 14 | 15 | - **Where did it change?**. When we have referencing it is not enough to update objects across each other, we also have to know if a change to object A affects object B, object B also has a change. This is what Mobx does a really great job on, but it is not a single state tree 16 | 17 | So here we are. I want a single state tree that has controlled mutations, allowing referencing and emits what changed along with any references. **This is rather low level code that would require abstractions for a good API, but it is a start :-)** 18 | 19 | Translated into code: 20 | 21 | ```js 22 | const tree = StateTree({ 23 | title: 'Whatap!', 24 | contacts: contacts, 25 | posts: [] 26 | }); 27 | 28 | function addPost() { 29 | // We just add a new post and reference 30 | // a user from somewhere else in our state tree 31 | tree.push('posts', { 32 | title: 'Some post', 33 | user: tree.get('contacts.0') 34 | }); 35 | tree.flushChanges(); // Components subscribes to these flushes 36 | } 37 | 38 | function changeName() { 39 | // We change the user in the contacts 40 | tree.set('contacts.0.name', 'Just a test'); 41 | 42 | // The component subscribing to "posts" will still 43 | // be notified about an update because one of the posts 44 | // has this user referenced 45 | tree.flushChanges(); 46 | } 47 | ``` 48 | 49 | ### API 50 | 51 | #### Instantiate 52 | ```js 53 | import StateTree from 'state-tree'; 54 | const tree = StateTree({ 55 | foo: 'bar' 56 | }); 57 | ``` 58 | 59 | #### Get state 60 | ```js 61 | import StateTree from 'state-tree'; 62 | const tree = StateTree({ 63 | foo: { 64 | bar: 'value' 65 | } 66 | }); 67 | 68 | tree.get('foo.bar'); // "value" 69 | ``` 70 | 71 | #### Change state 72 | ```js 73 | import StateTree from 'state-tree'; 74 | const tree = StateTree({ 75 | foo: 'bar', 76 | list: [] 77 | }); 78 | 79 | // You can also use arrays for the path 80 | tree.set(['foo'], 'bar2'); 81 | 82 | tree.set('foo', 'bar2'); 83 | tree.merge('foo', {something: 'cool'}); 84 | tree.import({foo: 'bar', deeply: {nested: 'foo'}}); // Deep merging 85 | tree.unset('foo'); 86 | tree.push('list', 'something'); 87 | tree.pop('list'); 88 | tree.shift('list'); 89 | tree.unshift('list', 'someValue'); 90 | tree.splice('list', 0, 1, 'newValue'); 91 | tree.concat('list', ['something']); 92 | ``` 93 | 94 | #### Flushing changes 95 | ```js 96 | import StateTree from 'state-tree'; 97 | const tree = StateTree({ 98 | foo: 'bar', 99 | list: [{ 100 | foo: 'bar' 101 | }] 102 | }); 103 | 104 | tree.set('foo', 'bar2'); 105 | tree.flushChanges(); // { foo: true } 106 | tree.set('list.0.foo', 'bar2'); 107 | 108 | // It returns an object representing the changes 109 | tree.flushChanges(); // { list: { 0: { foo: true } } } 110 | ``` 111 | 112 | With the flushed changes you decide when it is time to update the interface. You can use this flushed change tree with abstractions in your UI. An example could be: 113 | 114 | - Subscribe to flushes on the tree in a component wrapper 115 | - Component wrapper are registered to specific paths, like `{ someList: 'foo.bar.list' }` 116 | - You can now: 117 | 118 | ```js 119 | tree.subscribe(function (changes) { 120 | var hasUpdate = listPath.reduce(function (changes, key) { 121 | return changes ? changes[key] : false; 122 | }, changes); // undefined 123 | if (hasUpdate) { 124 | component.forceUpdate(); 125 | } 126 | }) 127 | ``` 128 | 129 | In this case, if we updated "foo" and the UI registers to "list" it would not update. This logic is instead of having a register of observables. This also make sure that when you for example register to "list" and a change happens on `{ list: { 0: true } }` the component will still update, which it should. 130 | 131 | ### Referencing 132 | So with this lib you can have a list of users and just add them to the posts. 133 | 134 | ```js 135 | import StateTree from 'state-tree'; 136 | const userA = { 137 | name: 'john' 138 | }; 139 | const tree = StateTree({ 140 | users: [userA], 141 | posts: [{ 142 | title: 'Some post', 143 | user: userA 144 | }] 145 | }); 146 | 147 | tree.set('users.0.name', 'woop'); 148 | tree.flushChanges(); // { users: { 0: true }, posts: { 0: { user: true } } } 149 | ``` 150 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import StateTree from './lib/stateTree' 2 | import Computed from './lib/computed' 3 | 4 | interface StateTreeFactory { 5 | (initialState): StateTree 6 | computed: (deps: any, cb: any) => Computed 7 | } 8 | 9 | declare const stateTree: StateTreeFactory 10 | 11 | export = stateTree 12 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // class factories exported as CommonJS module "default" 2 | var StateTree = require('./lib/stateTree').default 3 | 4 | module.exports = function (initialState) { 5 | return new StateTree(initialState) 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "state-tree", 3 | "version": "0.3.1", 4 | "description": "A state tree that handles reference updates and lets you flush a description of changes", 5 | "main": "index.js", 6 | "typings": "index.d.ts", 7 | "directories": { 8 | "test": "tests" 9 | }, 10 | "scripts": { 11 | "pretest": "npm run build", 12 | "test": "nodeunit tests", 13 | "build": "tsc", 14 | "prepublish": "npm run build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/cerebral-legacy/state-tree.git" 19 | }, 20 | "keywords": [ 21 | "state", 22 | "reference" 23 | ], 24 | "author": "Christian Alfoni ", 25 | "contributors": [ 26 | "Aleksey Guryanov " 27 | ], 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/cerebral-legacy/state-tree/issues" 31 | }, 32 | "homepage": "https://github.com/cerebral-legacy/state-tree#readme", 33 | "devDependencies": { 34 | "nodeunit": "^0.9.1", 35 | "typescript": "^1.8.10" 36 | }, 37 | "files": [ 38 | "lib/", 39 | "react/", 40 | "index.js" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/references.ts: -------------------------------------------------------------------------------- 1 | export function cleanReferences(rootObj, state, originPath) { 2 | if (typeof rootObj !== 'object' && rootObj !== null) { 3 | return 4 | } 5 | 6 | function removeReferences(references, originPath) { 7 | references.forEach(function (reference) { 8 | var obj = reference.reduce(function (currentPath, key) { 9 | if (typeof key === 'string') { 10 | return currentPath[key] 11 | } else { 12 | return currentPath[key[0].indexOf(key[1])] 13 | } 14 | }, state) 15 | obj['.referencePaths'] = obj['.referencePaths'].filter(function (currentReference) { 16 | return currentReference.map(function (key) { 17 | if (typeof key === 'string' || typeof key === 'number') { 18 | return key 19 | } else { 20 | return key[0].indexOf(key[1]) 21 | } 22 | }).join('.') !== originPath.join('.') // Might be slow on large arrays 23 | }) 24 | }) 25 | } 26 | function traverse(obj, currentPath) { 27 | if (Array.isArray(obj)) { 28 | if (obj['.referencePaths']) { 29 | obj.forEach(function (item, index) { 30 | currentPath.push(index) 31 | traverse(item, currentPath) 32 | currentPath.pop() 33 | }) 34 | removeReferences(obj['.referencePaths'], currentPath) 35 | if (!obj['.referencePaths'].length) { 36 | delete obj['.referencePaths'] 37 | } 38 | } 39 | } else if (typeof obj === 'object' && obj !== null) { 40 | if (obj['.referencePaths']) { 41 | Object.keys(obj).forEach(function (key) { 42 | currentPath.push(key) 43 | traverse(obj[key], currentPath) 44 | currentPath.pop() 45 | }) 46 | removeReferences(obj['.referencePaths'], currentPath) 47 | if (!obj['.referencePaths'].length) { 48 | delete obj['.referencePaths'] 49 | } 50 | } 51 | } 52 | } 53 | 54 | traverse(rootObj, originPath) 55 | } 56 | 57 | export function setReferences(rootObj, basePath) { 58 | function traverse(obj, path) { 59 | if (typeof obj === 'function') { 60 | throw new Error('You can not pass functions into the state tree. This happens on path: ' + path) 61 | } 62 | if (Array.isArray(obj)) { 63 | Object.defineProperty(obj, '.referencePaths', { 64 | writable: true, 65 | configurable: true, 66 | value: obj['.referencePaths'] ? obj['.referencePaths'].concat([path.slice()]) : [path.slice()] 67 | }) 68 | obj.forEach(function (item, index) { 69 | path.push([obj, item]) 70 | traverse(item, path) 71 | path.pop() 72 | }) 73 | return obj 74 | } else if (typeof obj === 'object' && obj !== null) { 75 | Object.defineProperty(obj, '.referencePaths', { 76 | writable: true, 77 | configurable: true, 78 | value: obj['.referencePaths'] ? obj['.referencePaths'].concat([path.slice()]) : [path.slice()] 79 | }) 80 | Object.keys(obj).forEach(function (key) { 81 | path.push(key) 82 | traverse(obj[key], path) 83 | path.pop(key) 84 | }) 85 | return obj 86 | } 87 | 88 | return obj 89 | } 90 | return traverse(rootObj, basePath) 91 | } 92 | -------------------------------------------------------------------------------- /src/stateTree.ts: -------------------------------------------------------------------------------- 1 | import { setReferences, cleanReferences } from './references' 2 | import { deepmerge, getByPath } from './utils' 3 | 4 | export type Callback = (changes: any) => void 5 | 6 | class StateTree { 7 | static setReferences = setReferences 8 | static cleanReferences = cleanReferences 9 | static getByPath = getByPath 10 | 11 | private _state: any 12 | private _subscribers: Callback[] = [] 13 | private _changes: any = {} 14 | 15 | constructor (initialState: any) { 16 | this._state = StateTree.setReferences(initialState, []) 17 | } 18 | 19 | private _updateChanges(host, key) { 20 | function update(pathArray) { 21 | return function (currentPath, key, index) { 22 | if (Array.isArray(key)) { 23 | key = key[0].indexOf(key[1]) 24 | currentPath[key] = index === pathArray.length - 1 ? true : {} 25 | } else if (index === pathArray.length - 1 && !currentPath[key]) { 26 | currentPath[key] = true 27 | } else if (index < pathArray.length - 1) { 28 | currentPath[key] = typeof currentPath[key] === 'object' ? currentPath[key] : {} 29 | } 30 | return currentPath[key] 31 | } 32 | } 33 | host['.referencePaths'].forEach((path) => { 34 | var pathArray = path ? path.concat(key) : [key] 35 | pathArray.reduce(update(pathArray), this._changes) 36 | }) 37 | } 38 | 39 | get (path) { 40 | path = path ? typeof path === 'string' ? path.split('.') : path : [] 41 | return StateTree.getByPath(path, this._state) 42 | } 43 | 44 | set (path, value) { 45 | var pathArray = typeof path === 'string' ? path.split('.') : path 46 | var originalPath = pathArray.slice() 47 | var key = pathArray.pop() 48 | var host = StateTree.getByPath(pathArray, this._state, true) 49 | StateTree.cleanReferences(host[key], this._state, originalPath) 50 | host[key] = StateTree.setReferences(value, pathArray.concat(key)) 51 | this._updateChanges(host, key) 52 | } 53 | 54 | push (path, value) { 55 | var pathArray = typeof path === 'string' ? path.split('.') : path 56 | var key = pathArray.pop() 57 | var host = StateTree.getByPath(pathArray, this._state) 58 | var length = host[key].push(setReferences(value, pathArray.concat(key, [[host[key], value]]))) 59 | this._updateChanges(host[key], String(length - 1)) 60 | } 61 | 62 | unshift (path, value) { 63 | var pathArray = typeof path === 'string' ? path.split('.') : path.slice() 64 | var key = pathArray.pop() 65 | var host = StateTree.getByPath(pathArray, this._state) 66 | var length = host[key].unshift(setReferences(value, pathArray.concat(key, [[host[key], value]]))) 67 | this._updateChanges(host[key], String(0)) 68 | } 69 | 70 | unset (path) { 71 | var pathArray = typeof path === 'string' ? path.split('.') : path 72 | var originalPath = pathArray.slice() 73 | var key = pathArray.pop() 74 | var host = StateTree.getByPath(pathArray, this._state) 75 | StateTree.cleanReferences(host[key], this._state, originalPath) 76 | delete host[key] 77 | this._updateChanges(host, key) 78 | } 79 | 80 | shift (path) { 81 | var pathArray = typeof path === 'string' ? path.split('.') : path.slice() 82 | var originalPath = pathArray.slice() 83 | var key = pathArray.pop() 84 | var host = StateTree.getByPath(pathArray, this._state) 85 | cleanReferences(host[key][0], this._state, originalPath.concat(0)) 86 | host[key].shift() 87 | this._updateChanges(host[key], String(0)) 88 | } 89 | 90 | splice () { 91 | var args = [].slice.call(arguments) 92 | var path = args.shift() 93 | var fromIndex = args.shift() 94 | var length = args.shift() 95 | var pathArray = typeof path === 'string' ? path.split('.') : path 96 | var originalPath = pathArray.slice() 97 | var key = pathArray.pop() 98 | var host = StateTree.getByPath(pathArray, this._state) 99 | // Clear references on existing items and set update path 100 | for (var x = fromIndex; x < fromIndex + length; x++) { 101 | cleanReferences(host[key][x], this._state, originalPath.slice().concat(x)) 102 | this._updateChanges(host[key], String(x)) 103 | } 104 | host[key].splice.apply(host[key], [fromIndex, length].concat(args.map(function (arg) { 105 | return setReferences(arg, pathArray.slice().concat(key, [[host[key], arg]])) 106 | }))) 107 | } 108 | 109 | pop (path) { 110 | var pathArray = typeof path === 'string' ? path.split('.') : path.slice() 111 | var originalPath = pathArray.slice() 112 | var key = pathArray.pop() 113 | var host = StateTree.getByPath(pathArray, this._state) 114 | var lastIndex = host[key].length - 1 115 | cleanReferences(host[key][lastIndex], this._state, originalPath.concat(lastIndex)) 116 | host[key].pop() 117 | this._updateChanges(host[key], String(lastIndex)) 118 | } 119 | 120 | merge () { 121 | var path 122 | var value 123 | if (arguments.length === 1) { 124 | path = '' 125 | value = arguments[0] 126 | } else { 127 | path = arguments[0] 128 | value = arguments[1] 129 | } 130 | var pathArray = typeof path === 'string' ? path.split('.') : path.slice() 131 | var originalPath = pathArray.slice() 132 | var key = pathArray.pop() 133 | var host = StateTree.getByPath(pathArray, this._state, true) 134 | var child = host[key] || host 135 | Object.keys(value).forEach((mergeKey) => { 136 | cleanReferences(child[mergeKey], this._state, key ? originalPath.slice().concat(mergeKey) : [mergeKey]) 137 | child[mergeKey] = setReferences(value[mergeKey], key ? pathArray.slice().concat(key, mergeKey) : [mergeKey]) 138 | this._updateChanges(child, mergeKey) 139 | }) 140 | } 141 | 142 | concat () { 143 | var args = [].slice.call(arguments) 144 | var path = args.shift() 145 | var pathArray = typeof path === 'string' ? path.split('.') : path.slice() 146 | var key = pathArray.pop() 147 | var host = StateTree.getByPath(pathArray, this._state) 148 | host[key] = host[key].concat.apply(host[key], args.map(function (arg) { 149 | return setReferences(arg, pathArray.slice().concat(key, [[host[key], arg]])) 150 | })) 151 | this._updateChanges(host, key) 152 | } 153 | 154 | import (value) { 155 | StateTree.cleanReferences(this._state, this._state, []) 156 | this._state = deepmerge(this._state, value) 157 | Object.keys(this._state).forEach((key) => { 158 | this._state[key] = setReferences(this._state[key], [key]) 159 | }) 160 | } 161 | 162 | subscribe (cb) { 163 | this._subscribers.push(cb) 164 | } 165 | 166 | unsubscribe (cb) { 167 | this._subscribers.splice(this._subscribers.indexOf(cb), 1) 168 | } 169 | 170 | flushChanges () { 171 | var flushedChanges = this._changes 172 | this._changes = {} 173 | this._subscribers.forEach(function (cb) { 174 | cb(flushedChanges) 175 | }) 176 | return flushedChanges 177 | } 178 | } 179 | 180 | export default StateTree 181 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function getByPath(path, state, forcePath?) { 2 | var currentPath = state 3 | for (var x = 0; x < path.length; x++) { 4 | var key = path[x] 5 | if (forcePath && currentPath[key] === undefined) { 6 | var newBranch = {} 7 | Object.defineProperty(newBranch, '.referencePaths', { 8 | writable: true, 9 | configurable: true, 10 | value: [path.slice().splice(0, x + 1)] 11 | }) 12 | currentPath[key] = newBranch 13 | } 14 | if (currentPath[key] === undefined) { 15 | return currentPath[key] 16 | } 17 | currentPath = currentPath[key] 18 | } 19 | return currentPath 20 | } 21 | 22 | export function deepmerge(target, src) { 23 | var array = Array.isArray(src) 24 | var dst = array && [] || {} 25 | 26 | if (array) { 27 | target = target || [] 28 | dst = src.slice() 29 | src.forEach(function(e, i) { 30 | if (typeof dst[i] === 'undefined') { 31 | dst[i] = e 32 | } else if (typeof e === 'object') { 33 | dst[i] = deepmerge(target[i], e) 34 | } 35 | }) 36 | } else { 37 | if (target && typeof target === 'object') { 38 | Object.keys(target).forEach(function (key) { 39 | dst[key] = target[key] 40 | }) 41 | } 42 | 43 | Object.keys(src).forEach(function (key) { 44 | if (typeof src[key] !== 'object' || !src[key]) { 45 | dst[key] = src[key] 46 | } else { 47 | if (!target[key]) { 48 | dst[key] = src[key] 49 | } else { 50 | dst[key] = deepmerge(target[key], src[key]) 51 | } 52 | } 53 | }) 54 | } 55 | 56 | return dst 57 | } 58 | -------------------------------------------------------------------------------- /tests/cleaning.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should remove references completely when cleaned and no other reference'] = function (test) { 8 | var item = {foo: 'bar'}; 9 | var lib = Lib({ 10 | foo: item 11 | }); 12 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo']]); 13 | lib.set('foo', 'bar'); 14 | test.ok(!item['.referencePaths']); 15 | test.done(); 16 | }; 17 | 18 | exports['should remove references when cleaned'] = function (test) { 19 | var item = {foo: 'bar'}; 20 | var lib = Lib({ 21 | foo: item, 22 | foo2: item 23 | }); 24 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo'], ['foo2']]); 25 | lib.set('foo', 'bar'); 26 | test.deepEqual(item['.referencePaths'], [['foo2']]); 27 | test.done(); 28 | }; 29 | 30 | exports['should remove references when cleaned inside arrays'] = function (test) { 31 | var item = {foo: 'bar'}; 32 | var lib = Lib({ 33 | foo: item, 34 | foo2: [{ 35 | user: item 36 | }] 37 | }); 38 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo'], ['foo2', [[{user: item}], {user: item}], 'user']]); 39 | lib.shift('foo2'); 40 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo']]); 41 | test.done(); 42 | }; 43 | 44 | exports['should remove references in complex structures'] = function (test) { 45 | var item = {foo: 'bar'}; 46 | var lib = Lib({ 47 | foo: item, 48 | foo2: [{ 49 | comments: [{ 50 | user: item 51 | }] 52 | }] 53 | }); 54 | test.deepEqual(lib.get('foo')['.referencePaths'], [ 55 | ['foo'], 56 | ['foo2', [[{comments: [{ user: item }]}], {comments: [{user: item}]}], 'comments', [[{user: item}], {user: item }], 'user'], 57 | ]); 58 | lib.shift('foo2'); 59 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo']]); 60 | test.done(); 61 | }; 62 | -------------------------------------------------------------------------------- /tests/concat.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to concat array'] = function (test) { 8 | var lib = Lib({ 9 | foo: ['foo', 'bar'] 10 | }); 11 | lib.concat('foo', 'hepp'); 12 | test.deepEqual(lib.get(), {foo: ['foo', 'bar', 'hepp']}); 13 | test.done(); 14 | }; 15 | 16 | 17 | exports['should be able to add references and not remove existing'] = function (test) { 18 | var objA = {fooA: 'bar'}; 19 | var objB = {fooB: 'bar'}; 20 | var lib = Lib({ 21 | foo: [objA] 22 | }); 23 | lib.concat('foo', objB); 24 | test.ok(objA['.referencePaths']); 25 | test.ok(objB['.referencePaths']); 26 | test.done(); 27 | }; 28 | 29 | exports['should show what changed'] = function (test) { 30 | var lib = Lib({ 31 | foo: ['foo'] 32 | }); 33 | lib.concat('foo', 'bar'); 34 | test.deepEqual(lib.flushChanges(), { foo: true }); 35 | test.done(); 36 | }; 37 | -------------------------------------------------------------------------------- /tests/import.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to import data'] = function (test) { 8 | var lib = Lib({ 9 | foo: 'foo' 10 | }); 11 | lib.import({bar: 'wuut'}); 12 | test.deepEqual(lib.get(), {foo: 'foo', bar: 'wuut'}); 13 | test.done(); 14 | }; 15 | 16 | exports['should clear out and reset references'] = function (test) { 17 | var objA = {foo: 'bar'}; 18 | var objB = {foo: 'bar'}; 19 | var lib = Lib({ 20 | foo: objA 21 | }); 22 | lib.import({foo: { foo: 'mip' }, bar: objB, bop: objB}); 23 | test.deepEqual(lib.get(), { foo: { foo: 'mip'}, bar: { foo: 'bar' }, bop: { foo: 'bar' }}); 24 | test.ok(!objA['.referencePaths']); 25 | test.deepEqual(objB['.referencePaths'], [['bar'], ['bop']]); 26 | test.done(); 27 | }; 28 | -------------------------------------------------------------------------------- /tests/instantiate.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should instantiate state with referencePaths'] = function (test) { 8 | var lib = Lib({ 9 | foo: 'bar' 10 | }); 11 | test.deepEqual(lib.get(), {foo: 'bar'}); 12 | test.ok('.referencePaths' in lib.get()); 13 | test.deepEqual(lib.get()['.referencePaths'], [[]]); 14 | test.done(); 15 | }; 16 | 17 | exports['should create reference paths for nested arrays and objects'] = function (test) { 18 | var item = {foo: 'bar'}; 19 | var lib = Lib({ 20 | foo: { 21 | bar: {} 22 | }, 23 | bar: [item] 24 | }); 25 | test.deepEqual(lib.get(), {foo: { bar: {} }, bar: [{foo: 'bar'}]}); 26 | test.ok('.referencePaths' in lib.get('foo')); 27 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo']]); 28 | test.deepEqual(lib.get('foo.bar')['.referencePaths'], [['foo', 'bar']]); 29 | test.deepEqual(lib.get('bar')['.referencePaths'], [['bar']]); 30 | test.deepEqual(lib.get('bar.0')['.referencePaths'], [['bar', [[item], item]]]); 31 | test.done(); 32 | }; 33 | 34 | exports['should create reference when object or array is reused'] = function (test) { 35 | var obj = {}; 36 | var array = []; 37 | var lib = Lib({ 38 | foo: obj, 39 | bar: array, 40 | foo2: obj, 41 | bar2: array 42 | }); 43 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo'], ['foo2']]); 44 | test.deepEqual(lib.get('bar')['.referencePaths'], [['bar'], ['bar2']]); 45 | test.equal(lib.get('foo'), lib.get('foo2')); 46 | test.equal(lib.get('bar'), lib.get('bar2')); 47 | test.done(); 48 | }; 49 | 50 | exports['should create reference correctly from within an array'] = function (test) { 51 | var obj = {foo: 'bar'}; 52 | var lib = Lib({ 53 | list: [{ 54 | user: obj 55 | }] 56 | }); 57 | test.deepEqual(lib.get('list.0.user')['.referencePaths'], [['list', [[{user: obj}], {user: obj}], 'user']]); 58 | test.done(); 59 | }; 60 | 61 | exports['should be able to get undefined from paths that are not in tree'] = function (test) { 62 | var lib = Lib({}); 63 | test.equals(lib.get('some.path'), undefined); 64 | test.done(); 65 | }; 66 | -------------------------------------------------------------------------------- /tests/merge.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to merge new values'] = function (test) { 8 | var lib = Lib({ 9 | foo: 'bar' 10 | }); 11 | lib.merge({ 12 | foo: 'bar2' 13 | }); 14 | test.deepEqual(lib.get(), {foo: 'bar2'}); 15 | test.done(); 16 | }; 17 | 18 | exports['should be able to merge values with reference'] = function (test) { 19 | var obj = {foo: 'bar'}; 20 | var lib = Lib({ 21 | foo: 'bar' 22 | }); 23 | lib.merge({ 24 | foo: obj 25 | }); 26 | test.deepEqual(obj['.referencePaths'], [['foo']]); 27 | test.done(); 28 | }; 29 | 30 | exports['should be able to merge values with reference in nested structure'] = function (test) { 31 | var obj = {foo: 'bar'}; 32 | var lib = Lib({ 33 | foo: { 34 | bar: null 35 | } 36 | }); 37 | lib.merge('foo', { 38 | bar: obj 39 | }); 40 | test.deepEqual(obj['.referencePaths'], [['foo', 'bar']]); 41 | test.done(); 42 | }; 43 | 44 | exports['should clear references when merging over values'] = function (test) { 45 | var obj = {foo: 'bar'}; 46 | var lib = Lib({ 47 | foo: { 48 | bar: obj 49 | } 50 | }); 51 | lib.merge('foo', { 52 | bar: 'bar' 53 | }); 54 | test.ok(!obj['.referencePaths']); 55 | test.done(); 56 | }; 57 | -------------------------------------------------------------------------------- /tests/pop.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to pop array'] = function (test) { 8 | var lib = Lib({ 9 | foo: ['foo', 'bar'] 10 | }); 11 | lib.pop('foo'); 12 | test.deepEqual(lib.get(), {foo: ['foo']}); 13 | test.done(); 14 | }; 15 | 16 | exports['should be able to pop values and remove any references'] = function (test) { 17 | var obj = {foo: 'bar'}; 18 | var lib = Lib({ 19 | foo: [obj] 20 | }); 21 | lib.pop('foo'); 22 | test.ok(!obj['.referencePaths']); 23 | test.done(); 24 | }; 25 | 26 | exports['should show what changed'] = function (test) { 27 | var lib = Lib({ 28 | foo: ['foo'] 29 | }); 30 | lib.pop('foo'); 31 | test.deepEqual(lib.flushChanges(), { foo: { 0: true } }); 32 | test.done(); 33 | }; 34 | -------------------------------------------------------------------------------- /tests/push.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to push new values'] = function (test) { 8 | var lib = Lib({ 9 | foo: [] 10 | }); 11 | lib.push('foo', 'bar2'); 12 | test.deepEqual(lib.get(), {foo: ['bar2']}); 13 | test.done(); 14 | }; 15 | 16 | exports['should be able to push new values with reference defined'] = function (test) { 17 | var lib = Lib({ 18 | foo: [] 19 | }); 20 | var item = {foo: 'bar'}; 21 | lib.push('foo', item); 22 | test.deepEqual(lib.get('foo.0')['.referencePaths'], [['foo', [[item], item]]]); 23 | test.done(); 24 | }; 25 | 26 | exports['should show change to array when pushed into'] = function (test) { 27 | var lib = Lib({ 28 | foo: [] 29 | }); 30 | lib.push('foo', {}); 31 | test.deepEqual(lib.flushChanges(), { foo: { 0: true }}); 32 | test.done(); 33 | }; 34 | 35 | exports['should change reference even if changed position in array'] = function (test) { 36 | var item = {foo: 'bar'}; 37 | var lib = Lib({ 38 | foo: [item], 39 | bar: item 40 | }); 41 | lib.unshift('foo', {}); 42 | lib.flushChanges(); 43 | lib.set('bar.foo', 'bar2'); 44 | test.deepEqual(lib.flushChanges(), { foo: { 1: {foo: true} }, bar: {foo: true}}); 45 | test.done(); 46 | }; 47 | -------------------------------------------------------------------------------- /tests/set.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to set new values'] = function (test) { 8 | var lib = Lib({ 9 | foo: 'bar' 10 | }); 11 | test.deepEqual(lib.get(), {foo: 'bar'}); 12 | lib.set('foo', 'bar2'); 13 | test.deepEqual(lib.get(), {foo: 'bar2'}); 14 | test.done(); 15 | }; 16 | 17 | exports['should show change on path set'] = function (test) { 18 | var lib = Lib({ 19 | foo: 'bar' 20 | }); 21 | lib.set('foo', 'bar2'); 22 | test.deepEqual(lib.flushChanges(), { foo: true }); 23 | test.done(); 24 | }; 25 | 26 | exports['should show change on nested set'] = function (test) { 27 | var lib = Lib({ 28 | foo: { 29 | bar: 'foo' 30 | } 31 | }); 32 | lib.set('foo.bar', 'foo2'); 33 | test.deepEqual(lib.flushChanges(), { foo: { bar: true } }); 34 | test.done(); 35 | }; 36 | 37 | exports['should show change on referenced set'] = function (test) { 38 | var obj = { foo: 'bar' }; 39 | var lib = Lib({ 40 | foo: obj, 41 | bar: obj 42 | }); 43 | lib.set('foo.foo', 'foo2'); 44 | test.deepEqual(lib.flushChanges(), { foo: { foo: true }, bar: { foo: true } }); 45 | test.done(); 46 | }; 47 | 48 | exports['should show change on nested array set'] = function (test) { 49 | var obj = { foo: 'bar' }; 50 | var lib = Lib({ 51 | foo: obj, 52 | bar: [{}, obj] 53 | }); 54 | lib.set('bar.1.foo', 'foo2'); 55 | test.deepEqual(lib.flushChanges(), { foo: { foo: true }, bar: { 1: { foo: true } } }); 56 | test.done(); 57 | }; 58 | 59 | exports['should be able to set paths that does not exist'] = function (test) { 60 | var lib = Lib({}); 61 | lib.set('foo.bar.test', 'bar2'); 62 | test.deepEqual(lib.get(), {foo: { bar: { test: 'bar2' } } }); 63 | test.deepEqual(lib.get('foo')['.referencePaths'], [['foo']]); 64 | test.deepEqual(lib.get('foo.bar')['.referencePaths'], [['foo', 'bar']]); 65 | test.done(); 66 | }; 67 | 68 | exports['should throw error when setting functions'] = function (test) { 69 | var lib = Lib({}); 70 | test.throws(function () { 71 | lib.set('foo.bar.test', function () {}); 72 | }) 73 | test.done(); 74 | } 75 | 76 | /* 77 | exports['should allow to override paths'] = function (test) { 78 | var lib = Lib({ 79 | foo: 'bar' 80 | }); 81 | var obj = {} 82 | lib.set('foo.bar', obj) 83 | test.ok(obj['.referencePaths']); 84 | test.done(); 85 | }; 86 | */ 87 | -------------------------------------------------------------------------------- /tests/shift.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to shift array'] = function (test) { 8 | var lib = Lib({ 9 | foo: ['foo', 'bar'] 10 | }); 11 | lib.shift('foo'); 12 | test.deepEqual(lib.get(), {foo: ['bar']}); 13 | test.done(); 14 | }; 15 | 16 | exports['should be able to shift values and remove any references'] = function (test) { 17 | var obj = {foo: 'bar'}; 18 | var lib = Lib({ 19 | foo: [obj] 20 | }); 21 | lib.shift('foo'); 22 | test.ok(!obj['.referencePaths']); 23 | test.done(); 24 | }; 25 | 26 | exports['should show what changed'] = function (test) { 27 | var lib = Lib({ 28 | foo: ['foo'] 29 | }); 30 | lib.shift('foo'); 31 | test.deepEqual(lib.flushChanges(), { foo: { 0: true } }); 32 | test.done(); 33 | }; 34 | -------------------------------------------------------------------------------- /tests/splice.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to splice an array'] = function (test) { 8 | var lib = Lib({ 9 | foo: ['foo', 'bar', 'zeta'] 10 | }); 11 | lib.splice('foo', 1, 1); 12 | test.deepEqual(lib.get(), {foo: ['foo', 'zeta']}); 13 | test.done(); 14 | }; 15 | 16 | exports['should be able to splice an array and add extra values'] = function (test) { 17 | var lib = Lib({ 18 | foo: ['foo', 'bar', 'zeta'] 19 | }); 20 | lib.splice('foo', 1, 1, 'mip'); 21 | test.deepEqual(lib.get(), {foo: ['foo', 'mip', 'zeta']}); 22 | test.done(); 23 | }; 24 | 25 | exports['should be able to splice and clear references'] = function (test) { 26 | var obj = { foo : 'bar' }; 27 | var lib = Lib({ 28 | foo: ['foo', obj, 'bar'] 29 | }); 30 | lib.splice('foo', 1, 1, 'mip'); 31 | test.deepEqual(lib.get(), {foo: ['foo', 'mip', 'bar']}); 32 | test.ok(!obj['.referencePaths']); 33 | test.done(); 34 | }; 35 | 36 | exports['should be able to splice, clear references and add references'] = function (test) { 37 | var obj = { foo : 'bar' }; 38 | var obj2 = { foo: 'bar' }; 39 | var lib = Lib({ 40 | foo: ['foo', obj, 'bar'] 41 | }); 42 | lib.splice('foo', 1, 1, obj2); 43 | test.deepEqual(lib.get(), {foo: ['foo', {foo: 'bar'}, 'bar']}); 44 | test.ok(!obj['.referencePaths']); 45 | test.deepEqual(obj2['.referencePaths'], [['foo', [['foo', {foo: 'bar'}, 'bar'], {foo: 'bar'}]]]); 46 | test.done(); 47 | }; 48 | 49 | exports['should be able to splice, clear references and add more than initial length'] = function (test) { 50 | var obj = { foo : 'bar' }; 51 | var lib = Lib({ 52 | foo: ['foo', obj, 'bar'] 53 | }); 54 | lib.splice('foo', 1, 1, 'mip', 'mop', 'bop'); 55 | test.deepEqual(lib.get(), {foo: ['foo', 'mip', 'mop', 'bop', 'bar']}); 56 | test.deepEqual(lib.flushChanges(), {foo: {1: true}}); 57 | test.done(); 58 | }; 59 | -------------------------------------------------------------------------------- /tests/unset.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to unset values'] = function (test) { 8 | var lib = Lib({ 9 | foo: 'bar' 10 | }); 11 | lib.unset('foo'); 12 | test.deepEqual(lib.get(), {}); 13 | test.done(); 14 | }; 15 | 16 | exports['should be able to unset values and remove any references'] = function (test) { 17 | var obj = {foo: 'bar'}; 18 | var lib = Lib({ 19 | foo: obj, 20 | bar: obj 21 | }); 22 | lib.unset('foo'); 23 | test.deepEqual(obj['.referencePaths'], [['bar']]); 24 | test.done(); 25 | }; 26 | 27 | exports['should show what changed'] = function (test) { 28 | var lib = Lib({ 29 | foo: 'bar' 30 | }); 31 | lib.unset('foo'); 32 | test.deepEqual(lib.flushChanges(), { foo: true }); 33 | test.done(); 34 | }; 35 | -------------------------------------------------------------------------------- /tests/unshift.js: -------------------------------------------------------------------------------- 1 | var Lib = require('../'); 2 | 3 | function log(obj) { 4 | console.log(JSON.stringify(obj, null, 2)) 5 | } 6 | 7 | exports['should be able to unshift new values'] = function (test) { 8 | var lib = Lib({ 9 | foo: ['foo'] 10 | }); 11 | lib.unshift('foo', 'bar2'); 12 | test.deepEqual(lib.get(), {foo: ['bar2', 'foo']}); 13 | test.done(); 14 | }; 15 | 16 | exports['should be able to unshift new values with reference defined'] = function (test) { 17 | var lib = Lib({ 18 | foo: ['foo'] 19 | }); 20 | var item = {foo: 'bar'}; 21 | lib.unshift('foo', item); 22 | test.deepEqual(lib.get('foo.0')['.referencePaths'], [['foo', [[item, 'foo'], item]]]); 23 | test.done(); 24 | }; 25 | 26 | exports['should show change to array when unshifted into'] = function (test) { 27 | var lib = Lib({ 28 | foo: ['foo'] 29 | }); 30 | lib.unshift('foo', {}); 31 | test.deepEqual(lib.flushChanges(), { foo: { 0: true }}); 32 | test.done(); 33 | }; 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "sourceMap": true, 5 | "noImplicitAny": false, 6 | "module": "commonjs", 7 | "outDir": "lib", 8 | "target": "es5" 9 | }, 10 | "exclude": [ 11 | "node_modules", 12 | "lib", 13 | "tests", 14 | "index.d.ts" 15 | ] 16 | } 17 | --------------------------------------------------------------------------------