├── .gitignore ├── .travis.yml ├── LICENSE ├── package.json ├── examples └── clickCounter.html ├── src ├── util │ └── react │ │ └── mixin.js └── index.js ├── test ├── util │ └── react │ │ └── mixin.js └── test.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | - "0.10" 5 | script: npm run-script test-travis 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jesse Skinner 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hover", 3 | "version": "3.0.0-alpha.6", 4 | "description": "Very lightweight (anti-gravity?) data store, with action reducers and state change listeners.", 5 | "main": "src/index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "size": "./node_modules/.bin/uglifyjs src/index.js -m -c | gzip | wc -c", 11 | "watch": "mocha --recursive -wGR nyan", 12 | "test-travis": "istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage", 13 | "test": "mocha --recursive" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/jesseskinner/hover.git" 18 | }, 19 | "keywords": [ 20 | "flux", 21 | "reactjs", 22 | "javascript" 23 | ], 24 | "author": "Jesse Skinner ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/jesseskinner/hover/issues" 28 | }, 29 | "homepage": "https://github.com/jesseskinner/hover", 30 | "devDependencies": { 31 | "chai": "^3.5.0", 32 | "coveralls": "^2.11.9", 33 | "istanbul": "^0.4.2", 34 | "mocha": "^2.4.5", 35 | "mocha-lcov-reporter": "^1.2.0", 36 | "uglify-js": "^2.6.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/clickCounter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hover Example 6 | 7 | 8 | 9 | 10 | 11 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/util/react/mixin.js: -------------------------------------------------------------------------------- 1 | /** 2 | React mixin, for easily subscribing and unsubscribing to a Hover store 3 | 4 | Usage: 5 | 6 | var SubscribeMixin = require('hover/src/util/mixin'); 7 | 8 | React.createClass({ 9 | mixins: [ 10 | // this will map the state of myStore to this.state.store 11 | SubscribeMixin(myStore, 'store') 12 | ], 13 | 14 | render: function () { 15 | // use this.state.store 16 | } 17 | }); 18 | 19 | NOTE: Do not reuse a mixin, each mixin should be only used once. 20 | */ 21 | function SubscribeMixin(subscribe, key) { 22 | var unsubscribe; 23 | 24 | return { 25 | componentDidMount: function () { 26 | // this should never happen 27 | if (unsubscribe) { 28 | throw new Error('Cannot reuse a mixin.'); 29 | } 30 | 31 | unsubscribe = subscribe(function (data) { 32 | // by default, use the store's state as the component's state 33 | var state = data; 34 | 35 | // but if a key is provided, map the data to that key 36 | if (key) { 37 | state = {}; 38 | state[key] = data; 39 | } 40 | 41 | // update the component's state 42 | this.setState(state); 43 | }.bind(this)); 44 | }, 45 | 46 | componentWillUnmount: function () { 47 | // call the unsubscribe function returned from store.getState above 48 | if (unsubscribe) { 49 | unsubscribe(); 50 | 51 | // wipe the unsubscribe, so the mixin can be used again maybe 52 | unsubscribe = null; 53 | } 54 | } 55 | }; 56 | }; 57 | 58 | // export for CommonJS 59 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 60 | module.exports = SubscribeMixin; 61 | } 62 | -------------------------------------------------------------------------------- /test/util/react/mixin.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | var expect = chai.expect; 3 | var mixin = require('../../../src/util/react/mixin'); 4 | 5 | describe('mixin', function () { 6 | describe('init', function () { 7 | it('should return an object with two functions', function () { 8 | var obj = mixin(); 9 | 10 | expect(obj.componentDidMount).to.be.a('function'); 11 | expect(obj.componentWillUnmount).to.be.a('function'); 12 | }); 13 | }); 14 | 15 | describe('componentDidMount', function () { 16 | it('should call getState on the store, which calls setState on the component', function () { 17 | var obj = mixin(function (callback) { 18 | callback(123); 19 | }); 20 | 21 | obj.setState = function (state) { 22 | expect(state).to.equal(123); 23 | }; 24 | 25 | obj.componentDidMount(); 26 | }); 27 | 28 | it('should also work with a state key', function () { 29 | var obj = mixin(function (callback) { 30 | callback(123); 31 | }, 'key'); 32 | 33 | obj.setState = function (state) { 34 | expect(state.key).to.equal(123); 35 | }; 36 | 37 | obj.componentDidMount(); 38 | }); 39 | 40 | it('should not allow a mixin to be reused', function () { 41 | var obj = mixin(function () { 42 | return function () {}; 43 | }); 44 | 45 | obj.setState = function () {}; 46 | 47 | obj.componentDidMount(); 48 | 49 | expect(function () { 50 | // second time should throw error 51 | obj.componentDidMount(); 52 | 53 | }).to.throw('Cannot reuse a mixin.'); 54 | }); 55 | }); 56 | 57 | describe('componentWillUnmount', function () { 58 | it('should call the function returned by getState, only once', function () { 59 | var called = 0, 60 | obj = mixin(function () { 61 | return function () { 62 | called++; 63 | }; 64 | }); 65 | 66 | obj.componentDidMount(); 67 | obj.componentWillUnmount(); 68 | obj.componentWillUnmount(); 69 | obj.componentWillUnmount(); 70 | 71 | expect(called).to.equal(1); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var Hover = (function(){ 2 | 3 | var slice = [].slice, undefined; 4 | 5 | // remove item from array, returning a new array 6 | function removeFromArray(array, removeItem) { 7 | var newArray = [].concat(array); 8 | var index = newArray.indexOf(removeItem); 9 | 10 | if (index !== -1) { 11 | newArray.splice(index, 1); 12 | } 13 | 14 | return newArray; 15 | } 16 | 17 | function isFunction(fn) { 18 | return typeof fn === 'function'; 19 | } 20 | 21 | // return the Hover function 22 | // actions is an iterable of reducers 23 | // state is undefined by default, but initial state can be provided 24 | function Hover(actions, state) { 25 | // list of state listeners specific to this store instance 26 | var stateListeners = [], 27 | 28 | // this is the store that will be returned 29 | store = function (callback) { 30 | // passing a function here is a synonym for subscribe 31 | if (isFunction(callback)) { 32 | // add callback as listener to change event 33 | stateListeners.push(callback); 34 | 35 | // call callback right away 36 | callback(state); 37 | 38 | // return an unsubscribe function specific to this listener 39 | return function () { 40 | // only call removeListener once, then destroy the callback 41 | if (callback) { 42 | stateListeners = removeFromArray(stateListeners, callback); 43 | callback = undefined; 44 | } 45 | }; 46 | } 47 | 48 | // return state 49 | return state; 50 | }, 51 | 52 | notify = function () { 53 | // let all the subscribers know what just happened 54 | for (var i=0, listeners = stateListeners.slice(); i < listeners.length; i++) { 55 | listeners[i](state); 56 | } 57 | }, 58 | 59 | // create an action for the api that calls an action handler and changes the state 60 | createAction = function (reducer) { 61 | // return a function that'll be attached to the api 62 | return function () { 63 | // convert arguments to a normal array 64 | var args = slice.call(arguments, 0), 65 | 66 | isOriginalAction = !inAction, 67 | 68 | result; 69 | 70 | inAction = true; 71 | 72 | // reduce the state & args into the new state 73 | result = reducer.apply(null, [state].concat(args)); 74 | 75 | // if result is a function, it's for async purposes 76 | if (isFunction(result)) { 77 | // pass setState and getState to be used later 78 | result(function (result) { 79 | state = result; 80 | notify(); 81 | }, function () { 82 | return state; 83 | }); 84 | 85 | } else { 86 | state = result; 87 | 88 | // only notify if this is the original action 89 | if (isOriginalAction) { 90 | notify(); 91 | } 92 | } 93 | 94 | if (isOriginalAction) { 95 | // this is done, there is no longer an action running 96 | inAction = false; 97 | } 98 | 99 | // return resulting state 100 | return state; 101 | }; 102 | }, 103 | 104 | inAction = false, 105 | 106 | method; 107 | 108 | // DEPRECATED: expose store as explicit api on the store 109 | store.getState = function (callback) { 110 | if (typeof console !== 'undefined' && typeof console.error === 'function') { 111 | console.error('Hover: store.getState() is deprecated. Use store() instead.'); 112 | } 113 | return store(callback); 114 | }; 115 | 116 | // create actions on the store api as well 117 | for (method in actions) { 118 | store[method] = createAction(actions[method]); 119 | } 120 | 121 | // return the store function as the exposed api 122 | return store; 123 | }; 124 | 125 | Hover.compose = function (definition) { 126 | var store = Hover({ 127 | s: function (state, newState) { 128 | for (var i=0; i < transforms.length; i++) { 129 | newState = transforms[i](newState); 130 | } 131 | 132 | return newState; 133 | } 134 | }), 135 | 136 | // we will use arguments once we're done initializing 137 | transforms = slice.call(arguments, 1), 138 | 139 | // in this case, we're exposing the raw setState, 140 | // so we'll need to use merge to make sure transforms get full state 141 | definitionIsFunction = isFunction(definition), 142 | 143 | // private setState method 144 | setState = store.s, 145 | 146 | initialized = false, 147 | 148 | key, 149 | 150 | subscribe = function (key) { 151 | var fn = store[key] = definition[key]; 152 | 153 | definition[key] = undefined; 154 | 155 | fn(function (state) { 156 | definition[key] = state; 157 | 158 | // pass to setState, but only after compose is done 159 | if (initialized) { 160 | setState(definition); 161 | } 162 | }); 163 | }, 164 | 165 | translateAction = function (key) { 166 | var action = definition[key]; 167 | 168 | return function () { 169 | action.apply(null, arguments); 170 | 171 | // return translated state 172 | return store(); 173 | }; 174 | }; 175 | 176 | delete store.s; 177 | 178 | if (definitionIsFunction) { 179 | definition(setState); 180 | 181 | for (key in definition) { 182 | if (isFunction(definition[key]) && definition[key] !== definition) { 183 | store[key] = translateAction(key); 184 | } 185 | } 186 | 187 | } else { 188 | if (definition) { 189 | // collect subscriptions without actually executing yet 190 | for (key in definition) { 191 | if (isFunction(definition[key])) { 192 | subscribe(key); 193 | } 194 | } 195 | } 196 | 197 | // call setState with final definition, so transforms can do their thing 198 | setState(definition); 199 | 200 | initialized = true; 201 | } 202 | 203 | return store; 204 | }; 205 | 206 | return Hover; 207 | 208 | })(); // execute immediately 209 | 210 | // export for CommonJS 211 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 212 | module.exports = Hover; 213 | } 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | A very lightweight data store
4 | with action reducers
5 | and state change listeners.
6 |
7 |
8 |

9 | 10 | [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] 11 | [![Build Status][travis-image]][travis-url] [![Coverage Status][coveralls-image]][coveralls-url] 12 | [![Dependency status][david-dm-image]][david-dm-url] [![Dev Dependency status][david-dm-dev-image]][david-dm-dev-url] 13 | 14 | ## Installation 15 | 16 | You can use npm to install Hover, or [download the raw file here](https://raw.githubusercontent.com/jesseskinner/hover/master/src/index.js). 17 | 18 | For more information, check out the [Concept](#concept), [Usage](#usage), [Documentation](#documentation) and [FAQ](#faq) below. 19 | 20 | 21 | ``` 22 | npm install hover 23 | 24 | import Hover from 'hover' 25 | ``` 26 | 27 | ## Concept 28 | 29 | Hover is a place to keep your state. You can pass in an initial state, but the only way to change the state afterwards is through action reducers you define. 30 | 31 | The basic usage of Hover is: 32 | 33 | ```javascript 34 | // in store.js 35 | import Hover from 'hover' 36 | 37 | const actions = { 38 | increment: (state, amount) => state + amount 39 | } 40 | 41 | const initialState = 0 42 | 43 | export default new Hover(actions, initialState) 44 | 45 | // elsewhere 46 | import store from './store' 47 | const state = store.increment(2) 48 | ``` 49 | 50 | You can easily subscribe to state changes with Hover stores. You can pass a callback to the store. Your callback will be called immediately at first, and again whenever the state changes. Here's an example using vanilla DOM scripting to update the page: 51 | 52 | ```javascript 53 | function renderUserProfile (user) { 54 | if (user) { 55 | const root = document.getElementById('user-profile'), 56 | avatar = root.querySelector('.avatar'), 57 | name = root.querySelector('.name') 58 | 59 | // use the avatar url as an image source 60 | avatar.src = user.avatar 61 | 62 | // erase previous contents of name 63 | while (name.firstChild) { 64 | name.removeChild(name.firstChild) 65 | } 66 | 67 | // add name as a text node 68 | name.appendChild(document.createTextNode(user.name)) 69 | } 70 | } 71 | 72 | userStore(renderUserProfile) 73 | ``` 74 | 75 | Here's an example rendering a React component: 76 | 77 | ```jsx 78 | function renderUserProfile (user) { 79 | ReactDOM.render( 80 | , 81 | document.getElementById('user-profile') 82 | ) 83 | } 84 | 85 | userStore(renderUserProfile) 86 | ``` 87 | 88 | 89 | ## Usage 90 | 91 | Here's how you might use Hover to keep track of clicks with a clickCounter. 92 | 93 | ```javascript 94 | const actions = { 95 | click: (state, text) => ({ 96 | value: state.value + 1, 97 | log: state.log.concat(text) 98 | }), 99 | 100 | // go back to defaults 101 | reset: () => initialState 102 | } 103 | 104 | const initialState = 0 105 | 106 | const clickCounter = new Hover(actions, initialState) 107 | 108 | // listen to changes to the state 109 | const unsubscribe = clickCounter(clickState => 110 | document.write(JSON.stringify(clickState) + "
" 111 | ) 112 | 113 | clickCounter.click('first') 114 | clickCounter.click('second') 115 | 116 | // reset back to zero 117 | clickCounter.reset() 118 | 119 | unsubscribe() 120 | 121 | clickCounter.click("This won't show up") 122 | ``` 123 | 124 | If you run this example, you'll see this: 125 | 126 | ```javascript 127 | {"value":0,"log":[]} 128 | {"value":1,"log":["first"]} 129 | {"value":2,"log":["first","second"]} 130 | {"value":0,"log":[]} 131 | ``` 132 | 133 | To see how Hover can fit into a larger app, with React and a router, check out the [Hover TodoMVC](http://github.com/jesseskinner/hover-todomvc/). 134 | 135 | 136 | ## Documentation 137 | 138 | Hover is a function that takes an actions object and returns a store object. 139 | 140 | ### Syntax 141 | 142 | ```javascript 143 | store = new Hover(actions[, initialState]) 144 | ``` 145 | 146 | #### `actions` object 147 | 148 | - Any properties of the actions object will be exposed as methods on the returned `store` object. 149 | - If your state is a plain object, and you return plain objects from your actions, they will be shallow merged together. 150 | - Note that your actions will automatically receive `state` as the first parameter, followed by the arguments you pass in when calling it. 151 | 152 | ```javascript 153 | // store is synchronous, actions are setters 154 | store = new Hover({ 155 | items: (state, items) => ({ items }), 156 | error: (state, error) => ({ error }) 157 | }, {}) 158 | 159 | // load data asynchronously and call actions to change the state 160 | api.getItems((error, items) => { 161 | if (error) { 162 | return store.error(error) 163 | } 164 | 165 | store.items(items) 166 | }) 167 | 168 | // listen to the state, and respond to it accordingly 169 | store(state => { 170 | if (state.items) { 171 | renderItems(state.items) 172 | } else if (state.error) { 173 | alert('Error loading items!') 174 | } 175 | }) 176 | ``` 177 | 178 | #### Return value 179 | 180 | `store = new Hover(actions[, initialState])` 181 | 182 | ##### `store` object methods 183 | 184 | - `store()` 185 | 186 | - Returns the store's current state. 187 | 188 | - `unsubscribe = store(function)` 189 | 190 | - Adds a listener to the state of a store. 191 | 192 | - The listener callback will be called immediately, and again whenever the state changed. 193 | 194 | - Returns an unsubscribe function. Call it to stop listening to the state. 195 | 196 | ```javascript 197 | unsubscribe = store(state => console.log(state)) 198 | 199 | // stop listening 200 | unsubscribe() 201 | ``` 202 | 203 | - `state = store.action(arg0, arg1, ..., argN)` 204 | - Calls an action handler on the store, passing through any arguments. 205 | 206 | ```javascript 207 | store = new Hover({ 208 | add: (state, number) => state + number 209 | }, 0) 210 | 211 | result = store() // returns 0 212 | result = store.add(5) // returns 5 213 | result = store.add(4) // returns 9 214 | result = store() // returns 9 215 | ``` 216 | 217 | #### Hover.compose 218 | 219 | `Hover.compose` takes a definition and creates a store, 220 | subscribing to any store members of the definition. 221 | 222 | `Hover.compose` can take static variables, objects or arrays. 223 | 224 | ```javascript 225 | // create two stores 226 | const scoreStore = new Hover({ 227 | add: (state, score) => state + score 228 | }, 0) 229 | const healthStore = new Hover({ 230 | hit: (state, amount) => state - amount 231 | }, 100) 232 | 233 | // compose the two stores into a single store 234 | const gameStore = Hover.compose({ 235 | score: scoreStore, 236 | 237 | // create an anonymous store to nest objects 238 | character: Hover.compose({ 239 | health: healthStore 240 | }) 241 | }) 242 | 243 | // stores and actions can be accessed with the same structure 244 | gameStore.score.add(2) 245 | 246 | gameStore.character.health.hit(1) 247 | ``` 248 | 249 | You can also pass zero or more translate functions after your compose definition, 250 | to automatically translate or map the state every time it gets updated. 251 | 252 | These translate functions will receive a `state` argument, and must return the resulting state. 253 | 254 | ```javascript 255 | // create stores to contain the active and completed todos 256 | const activeTodoStore = Hover.compose(todoStore, todos => 257 | todos.filter(todo => todo.completed === false) 258 | ) 259 | 260 | const completedTodoStore = Hover.compose(todoStore, todos => 261 | todos.filter(todo => todo.completed === true) 262 | }) 263 | ``` 264 | 265 | ## FAQ 266 | 267 | *Q: How does Hover handle asynchronous loading of data from an API?* 268 | 269 | There are three ways to achieve this. One way is to load the API outside of the store, and call actions to pass in the loading state, data and/or error as it arrives: 270 | 271 | ```javascript 272 | const store = new Hover({ 273 | loading: (state, isLoading) => ({ isLoading }), 274 | data: (state, data) => ({ data }), 275 | error: (state, error) => ({ error }) 276 | }) 277 | 278 | store.loading(true) 279 | 280 | getDataFromAPI(params, (error, data) => { 281 | if (error) { 282 | return store.error(error) 283 | } 284 | 285 | store.data(data) 286 | }) 287 | ``` 288 | 289 | Another way is to make API calls from inside your actions. 290 | 291 | ```javascript 292 | const store = new Hover({ 293 | load: (state, params) => { 294 | getDataFromAPI(params, (error, data) => 295 | store.done(error, data) 296 | ) 297 | 298 | return { isLoading: true, error: null, data: null } 299 | }, 300 | done: (state, error, data) => ( 301 | { isLoading: false, error, data } 302 | ) 303 | }) 304 | 305 | store.load(params) 306 | ``` 307 | 308 | --- 309 | 310 | *Q: If Hover stores only have a single getter, how can I have something like getById?* 311 | 312 | If you have access to a list of items in the state, you can write code to search through the list. You could even have a function like this as a property of the store, before you export it, eg. 313 | 314 | ```javascript 315 | import Hover from 'hover' 316 | 317 | const initialState = [{ id: 1, name: 'one' }, /* etc... */ } 318 | 319 | const itemStore = new Hover({ 320 | add: (list, item) => list.concat(item) 321 | }, initialState) 322 | 323 | // add a helper function to the store 324 | itemStore.getById = id => 325 | list.filter(item => item.id === id).pop() 326 | 327 | // getAll 328 | const items = itemStore() 329 | 330 | // look up a specific item 331 | const item = itemStore.getById(5) 332 | 333 | ``` 334 | 335 | --- 336 | 337 | 338 | ## Versioning 339 | 340 | Hover follows [semver versioning](http://semver.org/). So you can be sure that the API won't change until the next major version. 341 | 342 | 343 | ## Testing 344 | 345 | Clone the GitHub repository, run `npm install`, and then run `npm test` to run the tests. Hover has 100% test coverage. 346 | 347 | 348 | ## Contributing 349 | 350 | Feel free to [fork this repository on GitHub](https://github.com/jesseskinner/hover/fork), make some changes, and make a [Pull Request](https://github.com/jesseskinner/hover/pulls). 351 | 352 | You can also [create an issue](https://github.com/jesseskinner/hover/issues) if you find a bug or want to request a feature. 353 | 354 | Any comments and questions are very much welcome as well. 355 | 356 | 357 | ## Author 358 | 359 | Jesse Skinner [@JesseSkinner](http://twitter.com/JesseSkinner) 360 | 361 | 362 | ## License 363 | 364 | MIT 365 | 366 | [coveralls-image]: https://coveralls.io/repos/jesseskinner/hover/badge.png 367 | [coveralls-url]: https://coveralls.io/r/jesseskinner/hover 368 | 369 | [npm-url]: https://npmjs.org/package/hover 370 | [downloads-image]: http://img.shields.io/npm/dm/hover.svg 371 | [npm-image]: http://img.shields.io/npm/v/hover.svg 372 | [travis-url]: https://travis-ci.org/jesseskinner/hover 373 | [travis-image]: http://img.shields.io/travis/jesseskinner/hover.svg 374 | [david-dm-url]:https://david-dm.org/jesseskinner/hover 375 | [david-dm-image]:https://david-dm.org/jesseskinner/hover.svg 376 | [david-dm-dev-url]:https://david-dm.org/jesseskinner/hover#info=devDependencies 377 | [david-dm-dev-image]:https://david-dm.org/jesseskinner/hover/dev-status.svg 378 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | var expect = chai.expect; 3 | var Hover = require('../src/index'); 4 | 5 | describe('hover', function () { 6 | 7 | describe('#init', function () { 8 | 9 | it('should return a function when passed an object', function () { 10 | expect(Hover({})).to.be.a('function'); 11 | }); 12 | 13 | it('should create actions from an object', function () { 14 | var store = Hover({ 15 | something: function(){} 16 | }); 17 | 18 | expect(store.something).to.be.a('function'); 19 | }); 20 | 21 | it('should create actions from a class', function () { 22 | var myClass = function(){}; 23 | myClass.prototype.something = function(){}; 24 | 25 | var store = Hover(new myClass); 26 | 27 | expect(store.something).to.be.a('function'); 28 | }); 29 | 30 | it('should create actions from an extended class', function () { 31 | var ParentClass = function(){}; 32 | ParentClass.prototype.something = function(){}; 33 | 34 | var ChildClass = function(){}; 35 | ChildClass.prototype = new ParentClass(); 36 | 37 | var store = Hover(new ChildClass); 38 | 39 | expect(store.something).to.be.a('function'); 40 | }); 41 | 42 | it('should create actions from a module', function () { 43 | function Module(){ 44 | return { 45 | test: function(){} 46 | }; 47 | }; 48 | 49 | var store = Hover(new Module); 50 | 51 | expect(store.test).to.be.a('function'); 52 | }); 53 | 54 | it('should not add anything to the original prototype', function () { 55 | var myClass = function(){}; 56 | var store = Hover(myClass); 57 | 58 | expect(myClass.prototype).to.be.empty; 59 | }); 60 | 61 | it('should not add anything to the original object', function () { 62 | var obj = {}; 63 | var store = Hover(obj); 64 | 65 | expect(obj).to.be.empty; 66 | }); 67 | 68 | it('should allow initial state to be provided', function () { 69 | var store = Hover({}, 123); 70 | 71 | expect(store()).to.equal(123); 72 | }); 73 | 74 | it('should allow initial state to be undefined', function () { 75 | var store = Hover({}); 76 | 77 | expect(store()).to.be.undefined; 78 | }); 79 | 80 | it('should allow initial state to be an object', function () { 81 | var store = Hover({}, {abc:123}); 82 | 83 | expect(store()).to.deep.equal({abc:123}); 84 | }); 85 | 86 | it('should allow initial state to be an array', function () { 87 | var store = Hover({}, [123]); 88 | 89 | expect(store()).to.deep.equal([123]); 90 | }); 91 | }); 92 | 93 | describe('#getState', function () { 94 | it('should work, but be deprecated', function () { 95 | expect(Hover({}, 123).getState()).to.equal(123); 96 | }); 97 | }); 98 | 99 | describe('#state', function () { 100 | it('should return undefined by default', function () { 101 | expect(Hover({})()).to.be.undefined; 102 | }); 103 | 104 | it('should not discard mutations between action handlers', function () { 105 | var store = Hover({ 106 | foo: function (state) { 107 | return { test: true }; 108 | }, 109 | bar: function (state) { 110 | expect(state.test).to.be.true; 111 | } 112 | }); 113 | 114 | store.foo(); 115 | store.bar(); 116 | }); 117 | 118 | it('should not return a different class instance after each action call', function () { 119 | var myClass = function(){}; 120 | var instance = new myClass(); 121 | 122 | var store = Hover({ 123 | reset: function () { 124 | return instance; 125 | }, 126 | ping: function (state) { 127 | return state; 128 | } 129 | }); 130 | 131 | store.reset(); 132 | store.ping(); 133 | 134 | expect(store() === instance).to.be.true; 135 | }); 136 | 137 | it('should return a different object after each action call', function () { 138 | var store = Hover({ 139 | reset: function () { 140 | return { value: 0 }; 141 | }, 142 | add: function (state, num) { 143 | return { 144 | value: state.value + num 145 | }; 146 | } 147 | }); 148 | 149 | store.reset(); 150 | 151 | store.add(2); 152 | var result1 = store(); 153 | 154 | store.add(3); 155 | var result2 = store(); 156 | 157 | expect(result1.value).to.equal(2); 158 | expect(result2.value).to.equal(5); 159 | expect(result1).not.to.equal(result2); 160 | }); 161 | 162 | it('should not prevent mutation of state passed to action', function () { 163 | var store = Hover({ 164 | init: function () { 165 | return { value: 'yay' }; 166 | }, 167 | mutate: function (state) { 168 | state.value = 'boo'; 169 | return state; 170 | } 171 | }); 172 | 173 | store.init(); 174 | store.mutate(); 175 | 176 | expect(store().value).to.equal('boo'); 177 | }); 178 | 179 | it('should not prevent mutation of state passed to subscriber', function () { 180 | var store = Hover({ 181 | action: function () { 182 | return { value: 'yay' }; 183 | } 184 | }); 185 | 186 | store.action(); 187 | 188 | store(function (state) { 189 | state.value = 'boo'; 190 | }); 191 | 192 | expect(store().value).to.equal('boo'); 193 | }); 194 | 195 | it('should allow actions to call other actions, but only notify at the end', function () { 196 | var store = Hover({ 197 | action: function () { 198 | var five = store.otherAction(5); 199 | 200 | expect(five).to.equal(5); 201 | 202 | return 'action'; 203 | }, 204 | otherAction: function (state, num) { 205 | return num; 206 | } 207 | }); 208 | 209 | var count = 0; 210 | 211 | store(function (state) { 212 | if (count++ === 1) { 213 | expect(state).to.equal('action'); 214 | } 215 | expect(state).to.not.equal('other'); 216 | }); 217 | 218 | expect(store.action()).to.equal('action'); 219 | 220 | }); 221 | }); 222 | 223 | describe('#action()', function () { 224 | 225 | it('should call an action handler', function (done) { 226 | var store = Hover({ 227 | action: function () { 228 | done(); 229 | } 230 | }); 231 | 232 | store.action(); 233 | }); 234 | 235 | it('should still work with zero, one, two, three or four arguments', function () { 236 | var store = Hover({ 237 | action: function (state, arg1, arg2, arg3, arg4) { 238 | return { len: arguments.length - 1 }; 239 | } 240 | }); 241 | 242 | store.action(); 243 | expect(store().len).to.equal(0); 244 | 245 | store.action(1); 246 | expect(store().len).to.equal(1); 247 | 248 | store.action(1,2); 249 | expect(store().len).to.equal(2); 250 | 251 | store.action(1,2,3); 252 | expect(store().len).to.equal(3); 253 | 254 | store.action(1,2,3,4); 255 | expect(store().len).to.equal(4); 256 | }); 257 | 258 | it('should return state', function () { 259 | var store = Hover({ 260 | add: function (state, num) { 261 | return (state || 0) + num; 262 | } 263 | }); 264 | 265 | expect(store.add(4)).to.equal(4); 266 | expect(store.add(2)).to.equal(6); 267 | }); 268 | 269 | it('should allow returning a function for async state changes', function () { 270 | var setState, getState 271 | var store = Hover({ 272 | async: function () { 273 | return function (a, b) { 274 | setState = a; 275 | getState = b; 276 | }; 277 | }, 278 | sync: function (state) { 279 | return state 280 | } 281 | }, 123); 282 | var updateCount = 0; 283 | 284 | store(function (state) { 285 | updateCount++; 286 | }); 287 | 288 | var state = store.async(); 289 | 290 | store.sync(); 291 | 292 | expect(state).to.equal(123); 293 | expect(store()).to.equal(123); 294 | expect(getState()).to.equal(123); 295 | 296 | setState(456); 297 | 298 | expect(store()).to.equal(456); 299 | expect(getState()).to.equal(456); 300 | 301 | expect(updateCount).to.equal(3); 302 | }); 303 | }); 304 | 305 | describe('#(function)', function () { 306 | 307 | it('should allow state listeners on stores', function (done) { 308 | var store = Hover({ 309 | update: function (state, newState) { 310 | return newState; 311 | } 312 | }); 313 | 314 | store(function (state) { 315 | if (state === 123) { 316 | done(); 317 | } 318 | }); 319 | 320 | store.update(123); 321 | }); 322 | 323 | it('should return an unsubscribe function', function (done) { 324 | var store = Hover({ 325 | update: function (state, data) { 326 | return data; 327 | } 328 | }), 329 | 330 | unsubscribe = store(function (state) { 331 | if (state === 1) { 332 | throw "I should not be called"; 333 | } 334 | }); 335 | 336 | store(function (state) { 337 | // this should be called 338 | if (state === 1) { 339 | done(); 340 | } 341 | }); 342 | 343 | // unsubscribe the first one 344 | unsubscribe(); 345 | 346 | // second time does nothing 347 | unsubscribe(); 348 | 349 | // trigger an update 350 | store.update(1); 351 | }); 352 | 353 | it('should unsubscribe without breaking other listeners', function () { 354 | var store = Hover({ 355 | update: function (state, data) { 356 | return data; 357 | } 358 | }), 359 | 360 | success = false, 361 | 362 | unsubscribe = store(function (state) { 363 | if (state === 1) { 364 | unsubscribe(); 365 | } 366 | }); 367 | 368 | store(function (state) { 369 | if (state === 1) { 370 | success = true; 371 | } 372 | }); 373 | 374 | // trigger an update 375 | store.update(1); 376 | 377 | expect(success).to.be.true; 378 | }); 379 | }); 380 | 381 | describe('Hover.compose', function () { 382 | 383 | it('should take in static variables', function () { 384 | var store = Hover.compose(123); 385 | 386 | expect(store()).to.equal(123); 387 | }); 388 | 389 | it('should take in functions', function () { 390 | var store = Hover.compose(function (setState) { 391 | setState(456); 392 | }); 393 | 394 | expect(store()).to.equal(456); 395 | }); 396 | 397 | it('should pass the state in to transforms when using functions', function () { 398 | var lastState; 399 | var store = Hover.compose(function (setState) { 400 | setState({ a: 1 }); 401 | setState({ b: 2 }); 402 | }, function (state) { 403 | lastState = state; 404 | return state; 405 | }); 406 | 407 | expect(lastState.b).to.equal(2); 408 | expect(lastState.a).to.be.undefined; 409 | }); 410 | 411 | it('should take in a store', function () { 412 | var storeA = Hover({ 413 | init: function (state, newState) { 414 | return newState; 415 | } 416 | }); 417 | 418 | var storeB = Hover.compose(storeA); 419 | 420 | expect(storeB()).to.be.undefined; 421 | 422 | storeA.init(789); 423 | 424 | expect(storeB()).to.equal(789); 425 | }); 426 | 427 | it('should take in an array of stores', function () { 428 | var storeA = Hover({ 429 | init: function (state, newState) { 430 | return newState; 431 | } 432 | }); 433 | var storeB = Hover({ 434 | init: function (state, newState) { 435 | return newState; 436 | } 437 | }); 438 | var mainStore = Hover.compose([ 439 | storeA, storeB 440 | ]); 441 | 442 | storeA.init(123); 443 | storeB.init(456); 444 | 445 | expect(mainStore().join(',')).to.equal('123,456'); 446 | }); 447 | 448 | it('should take in an empty array', function () { 449 | var store = Hover.compose([]); 450 | 451 | expect(store().length).to.equal(0); 452 | expect(store() instanceof Array).to.be.true; 453 | }); 454 | 455 | it('should take in an array of static variables', function () { 456 | var store = Hover.compose([ 123, 456 ]); 457 | 458 | expect(store().join(',')).to.equal('123,456'); 459 | }); 460 | 461 | it('should take in an object of stores', function () { 462 | var storeA = Hover({ 463 | init: function (state, newState) { 464 | return newState; 465 | } 466 | }); 467 | var storeB = Hover({ 468 | init: function (state, newState) { 469 | return newState; 470 | } 471 | }); 472 | var mainStore = Hover.compose({ 473 | a: storeA, 474 | b: storeB 475 | }); 476 | 477 | storeA.init(123); 478 | storeB.init(456); 479 | 480 | expect(mainStore().a).to.equal(123); 481 | expect(mainStore().b).to.equal(456); 482 | }); 483 | 484 | it('should take in an object of functions', function () { 485 | var store = Hover.compose({ 486 | a: function (setState) { 487 | setState(123); 488 | }, 489 | b: function (setState) { 490 | setState(456); 491 | } 492 | }); 493 | 494 | var state = store(); 495 | 496 | expect(state.a).to.equal(123); 497 | expect(state.b).to.equal(456); 498 | }); 499 | 500 | it('should be able to do all of this at once', function () { 501 | var storeA = Hover({ 502 | init: function (state, newState) { 503 | return newState; 504 | } 505 | }); 506 | var storeB = Hover({ 507 | init: function (state, newState) { 508 | return newState; 509 | } 510 | }); 511 | var mainStore = Hover.compose({ 512 | a: function (setState) { 513 | setState(123); 514 | }, 515 | b: function (setState) { 516 | setState(456); 517 | }, 518 | storeA: storeA, 519 | storeB: storeB, 520 | staticVar: 'hello', 521 | arr: Hover.compose([ 522 | storeA, 523 | storeB, 524 | function (setState) { 525 | setState('last') 526 | } 527 | ]), 528 | staticArr: ['test'] 529 | }); 530 | 531 | storeA.init(789); 532 | storeB.init('abc'); 533 | 534 | var state = mainStore(); 535 | 536 | expect(state.a).to.equal(123); 537 | expect(state.b).to.equal(456); 538 | expect(state.storeA).to.equal(789); 539 | expect(state.storeB).to.equal('abc'); 540 | expect(state.staticVar).to.equal('hello'); 541 | expect(state.arr.join(',')).to.equal('789,abc,last'); 542 | expect(state.staticArr.join(',')).to.equal('test'); 543 | }); 544 | 545 | it('should allow zero or more translate functions', function () { 546 | var storeA = Hover({ 547 | init: function (state, newState) { 548 | return newState; 549 | } 550 | }); 551 | var storeB = Hover.compose(storeA, function (state) { 552 | if (state) { 553 | state.a = 100; 554 | } 555 | return state; 556 | }, function (state) { 557 | if (state) { 558 | state.b += 50; 559 | } 560 | return state; 561 | }); 562 | 563 | expect(storeB()).to.be.undefined; 564 | 565 | storeA.init({ b: 200 }); 566 | 567 | expect(storeB().a).to.equal(100); 568 | expect(storeB().b).to.equal(250); 569 | }); 570 | 571 | it('should resolve functions to undefined', function () { 572 | var setState; 573 | var store = Hover.compose({ 574 | fn: function (s) { 575 | setState = s; 576 | } 577 | }); 578 | 579 | expect(store().fn).to.be.undefined; 580 | 581 | setState(1); 582 | 583 | expect(store().fn).to.equal(1); 584 | }); 585 | 586 | it('should resolve functions to undefined in translate function too', function () { 587 | var store = Hover.compose({ 588 | fn: function () {}, 589 | fn2: function (){} 590 | }, function (state) { 591 | expect('fn' in state).to.be.true; 592 | expect(state.fn).to.be.undefined; 593 | 594 | expect('fn2' in state).to.be.true; 595 | expect(state.fn2).to.be.undefined; 596 | }); 597 | }); 598 | 599 | it('should use the definition for arrays as the state container', function () { 600 | var fn = function (setState){ setState(1) }, 601 | defArray = [fn], 602 | storeArray = Hover.compose(defArray); 603 | 604 | expect(defArray[0]).to.equal(1); 605 | }); 606 | 607 | it('should use the definition for plain objects as the state container', function () { 608 | var fn = function (setState){ setState(2) }, 609 | defObject = { a: fn }, 610 | storeObject = Hover.compose(defObject); 611 | 612 | expect(defObject.a).to.equal(2); 613 | }); 614 | 615 | it('should not leak state through translations', function () { 616 | var store = Hover.compose({ 617 | a: Hover.compose(1) 618 | }, function () { 619 | return { b: 2 }; 620 | }); 621 | 622 | expect('a' in store()).to.be.false; 623 | }); 624 | 625 | it('should pass-through actions on simple wrapper', function () { 626 | var store = Hover({ 627 | action: function(state, newState) { return newState } 628 | }); 629 | 630 | var composed = Hover.compose(store); 631 | 632 | composed.action(123); 633 | 634 | expect(composed()).to.equal(123); 635 | }); 636 | 637 | it('should pass-through actions on translated store', function () { 638 | var store = Hover({ 639 | action: function(state, newState) { return newState } 640 | }); 641 | 642 | var composed = Hover.compose(store, function (state) { 643 | return state * 2 644 | }); 645 | 646 | expect(composed.action(123)).to.equal(246); 647 | }); 648 | 649 | it('should pass-through stores in object structure', function () { 650 | var store = Hover({ 651 | action: function(state, newState) { return newState } 652 | }); 653 | 654 | var composed = Hover.compose({ 655 | storeA: store, 656 | storeB: store, 657 | static: 5 658 | }); 659 | 660 | expect(composed.storeA).to.equal(store); 661 | expect(composed.storeA.action).to.equal(store.action); 662 | expect(composed.storeB).to.equal(store); 663 | expect(composed.static).to.be.undefined; 664 | }); 665 | 666 | it('should pass-through stores in array structure', function () { 667 | var store = Hover({ 668 | action: function(state, newState) { return newState } 669 | }); 670 | 671 | var composed = Hover.compose([ 672 | store, 673 | store 674 | ]); 675 | 676 | expect(composed[0]).to.equal(store) 677 | expect(composed[0].action).to.equal(store.action) 678 | expect(composed[1]).to.equal(store) 679 | }); 680 | 681 | it('should pass-through stores in nested structure', function () { 682 | var store = Hover({ 683 | action: function(state, newState) { return newState } 684 | }); 685 | 686 | var composed = Hover.compose({ 687 | things: Hover.compose({ 688 | store: store 689 | }) 690 | }); 691 | 692 | expect(composed.things.store).to.equal(store) 693 | expect(composed.things.store.action).to.equal(store.action) 694 | }); 695 | }); 696 | 697 | }); // hover 698 | --------------------------------------------------------------------------------