├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .istanbul.yml ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── lib ├── .eslintrc ├── data-fetcher.js ├── data-init.js ├── data-sender.js ├── data-watcher.js ├── index.js ├── state.js └── throw-error.js ├── package.json └── test ├── .babelrc ├── .eslintrc ├── helpers └── render.js ├── karma.build.js ├── karma.common.js ├── karma.dev.js ├── karma.travis.js └── lib ├── .eslintrc ├── data-fetcher.js ├── data-init.js ├── data-sender.js ├── data-watcher.js ├── index.js ├── state.js └── throw-error.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "stage-0", "react" ], 3 | "plugins": [ "transform-runtime" ] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.{json,yml}] 12 | indent_size = 2 13 | 14 | [{.babelrc,.eslintrc}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | docs/ 3 | coverage/ 4 | log/ 5 | tmp/ 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | parser: babel-eslint 3 | plugins: 4 | - babel 5 | 6 | env: 7 | es6: true 8 | node: true 9 | browser: true 10 | 11 | ecmaFeatures: 12 | modules: true 13 | 14 | rules: 15 | 16 | # Possible Errors 17 | comma-dangle: 18 | - 2 19 | - never 20 | no-cond-assign: 2 21 | no-console: 1 22 | no-constant-condition: 2 23 | no-control-regex: 1 24 | no-debugger: 2 25 | no-dupe-args: 2 26 | no-dupe-keys: 2 27 | no-duplicate-case: 2 28 | no-empty: 2 29 | no-empty-character-class: 2 30 | no-ex-assign: 2 31 | no-extra-boolean-cast: 2 32 | no-extra-parens: 33 | - 2 34 | - functions 35 | no-extra-semi: 2 36 | no-func-assign: 2 37 | no-inner-declarations: 2 38 | no-invalid-regexp: 2 39 | no-irregular-whitespace: 2 40 | no-negated-in-lhs: 2 41 | no-obj-calls: 2 42 | no-regex-spaces: 2 43 | no-reserved-keys: 0 # ? 44 | no-sparse-arrays: 2 45 | no-unreachable: 2 46 | use-isnan: 2 47 | valid-jsdoc: 0 # fuck off 48 | valid-typeof: 2 49 | 50 | # Best Practices 51 | accessor-pairs: 52 | - 1 53 | - setWithoutGet: true 54 | block-scoped-var: 0 55 | complexity: 0 # todo 56 | consistent-return: 0 57 | curly: 2 58 | default-case: 0 59 | dot-notation: 2 60 | dot-location: 0 61 | eqeqeq: 2 62 | guard-for-in: 2 63 | no-alert: 2 64 | no-caller: 2 65 | no-case-declarations: 2 66 | no-div-regex: 0 67 | no-else-return: 2 68 | no-empty-label: 2 69 | no-empty-pattern: 2 70 | no-eq-null: 2 71 | no-eval: 2 72 | no-extend-native: 2 73 | no-extra-bind: 2 74 | no-fallthrough: 2 75 | no-floating-decimal: 2 76 | no-implicit-coercion: 77 | - 2 78 | - boolean: true 79 | number: true 80 | string: true 81 | no-implied-eval: 2 82 | no-invalid-this: 0 83 | no-iterator: 2 84 | no-labels: 2 85 | no-lone-blocks: 2 86 | no-loop-func: 2 87 | no-magic-numbers: 88 | - 2 89 | - ignore: 90 | - -1 91 | - 0 92 | - 1 93 | - 2 94 | no-multi-spaces: 2 95 | no-multi-str: 2 96 | no-native-reassign: 2 97 | no-new: 2 98 | no-new-func: 2 99 | no-new-wrappers: 2 100 | no-octal: 2 101 | no-octal-escape: 2 102 | no-param-reassign: 1 # ? 103 | no-process-env: 0 104 | no-proto: 2 105 | no-redeclare: 2 106 | no-return-assign: 2 107 | no-script-url: 2 108 | no-self-compare: 2 109 | no-sequences: 2 110 | no-throw-literal: 2 111 | no-unused-expressions: 0 # ? (chai) 112 | no-useless-call: 2 113 | no-useless-concat: 2 114 | no-void: 2 115 | no-warning-comments: 0 116 | no-with: 2 117 | radix: 0 # since we don't use octal literals 118 | vars-on-top: 2 119 | wrap-iife: 2 120 | yoda: 2 121 | no-unexpected-multiline: 1 122 | 123 | # Strict Mode 124 | strict: 125 | - 2 126 | - never 127 | 128 | # Variables 129 | init-declarations: 1 130 | no-catch-shadow: 2 131 | no-delete-var: 2 132 | no-label-var: 2 133 | no-shadow: 2 134 | no-shadow-restricted-names: 2 135 | no-undef: 2 136 | no-undef-init: 2 137 | no-undefined: 2 138 | no-unused-vars: 139 | - 1 140 | - args: none 141 | no-use-before-define: 142 | - 2 143 | - nofunc 144 | 145 | # Node.js 146 | callback-return: 147 | - 1 148 | - 149 | - callback 150 | - done 151 | global-require: 0 152 | handle-callback-err: 2 153 | no-mixed-requires: 2 154 | no-new-require: 2 155 | no-path-concat: 2 156 | no-process-exit: 2 157 | no-restricted-modules: 0 158 | no-sync: 1 # ? 159 | 160 | # Stylistic Issues 161 | array-bracket-spacing: 162 | - 2 163 | - always 164 | - singleValue: true 165 | objectsInArrays: true 166 | arraysInArrays: true 167 | block-spacing: 168 | - 2 169 | - always 170 | indent: 171 | - 2 172 | - 4 173 | - SwitchCase: 1 174 | jsx-quotes: 175 | - 1 176 | - prefer-double 177 | brace-style: 178 | - 2 179 | - 1tbs 180 | - allowSingleLine: false 181 | camelcase: 0 # ? 182 | comma-spacing: 183 | - 2 184 | - before: false 185 | after: true 186 | comma-style: 187 | - 2 188 | - last 189 | computed-property-spacing: 0 # ? 190 | consistent-this: 191 | - 2 192 | - self 193 | eol-last: 2 194 | func-names: 0 195 | func-style: 0 # ? 196 | id-length: 197 | - 2 198 | - min: 2 199 | max: 35 200 | exceptions: 201 | - "i" 202 | - "e" 203 | lines-around-comment: 0 204 | key-spacing: 205 | - 2 206 | - beforeColon: false 207 | afterColon: true 208 | linebreak-style: 209 | - 2 210 | - unix 211 | max-nested-callbacks: # ? 212 | - 1 213 | - 3 214 | new-cap: 0 215 | new-parens: 2 216 | newline-after-var: 1 # ? 217 | no-array-constructor: 2 218 | no-continue: 0 219 | no-inline-comments: 0 220 | no-lonely-if: 2 221 | no-mixed-spaces-and-tabs: 2 222 | no-multiple-empty-lines: 223 | - 2 224 | - max: 2 225 | no-nested-ternary: 2 226 | no-negated-condition: 2 227 | no-new-object: 2 228 | no-restricted-syntax: 229 | - 0 230 | - DebuggerStatement 231 | - LabeledStatement 232 | - WithStatement 233 | - JSXIdentifier 234 | - JSXNamespacedName 235 | - JSXMemberExpression 236 | - JSXEmptyExpression 237 | - JSXExpressionContainer 238 | - JSXElement 239 | - JSXClosingElement 240 | - JSXOpeningElement 241 | - JSXAttribute 242 | - JSXSpreadAttribute 243 | - JSXText 244 | no-spaced-func: 2 245 | no-ternary: 0 246 | no-trailing-spaces: 2 247 | no-underscore-dangle: 0 248 | no-unneeded-ternary: 2 249 | object-curly-spacing: 250 | - 2 251 | - always 252 | one-var: 0 253 | operator-assignment: 254 | - 2 255 | - always 256 | operator-linebreak: 257 | - 2 258 | - after 259 | padded-blocks: 0 260 | quote-props: 261 | - 2 262 | - as-needed 263 | id-match: 0 264 | quotes: 265 | - 2 266 | - single 267 | require-jsdoc: 0 268 | semi: 269 | - 2 270 | - always 271 | semi-spacing: 272 | - 2 273 | - before: false 274 | after: true 275 | sort-vars: 0 276 | space-after-keywords: 277 | - 2 278 | - always 279 | space-before-keywords: 0 280 | space-before-blocks: 281 | - 2 282 | - always 283 | space-before-function-paren: 0 284 | space-in-parens: 285 | - 2 286 | - never 287 | space-infix-ops: 2 288 | space-return-throw-case: 2 289 | space-unary-ops: 290 | - 2 291 | - words: true 292 | nonwords: false 293 | spaced-comment: 294 | - 1 295 | - always 296 | - exceptions: 297 | - "*" 298 | wrap-regex: 0 299 | 300 | # ECMAScript 6 301 | arrow-body-style: 0 302 | arrow-parens: 0 303 | arrow-spacing: 2 304 | constructor-super: 2 305 | generator-star-spacing: 0 306 | no-arrow-condition: 2 307 | no-class-assign: 2 308 | no-const-assign: 2 309 | no-dupe-class-members: 2 310 | no-this-before-super: 2 311 | no-var: 2 312 | object-shorthand: 0 313 | prefer-arrow-callback: 0 314 | prefer-const: 2 315 | prefer-spread: 1 316 | prefer-reflect: 0 # todo 317 | prefer-template: 0 318 | require-yield: 1 319 | 320 | # Legacy 321 | max-depth: 0 # ? 322 | max-len: 323 | - 1 324 | - 100 325 | - 4 326 | - ignoreComments: true 327 | ignoreUrls: true 328 | # ignorePattern: "^\\s*var\\s.+=\\s*require\\s*\\(" todo https://github.com/eslint/eslint/blob/master/docs/rules/max-len.md 329 | 330 | max-params: 331 | - 1 332 | - 4 333 | max-statements: 0 # ? 334 | no-bitwise: 2 335 | no-plusplus: 0 336 | 337 | # Babel 338 | babel/object-shorthand: 339 | - 1 340 | - always 341 | babel/generator-star-spacing: 342 | - 2 343 | - after 344 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | tmp/ 3 | build/ 4 | coverage/ 5 | *.log 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | verbose: false 2 | instrumentation: 3 | root: . 4 | reporting: 5 | print: none 6 | reports: 7 | - lcovonly 8 | - text 9 | dir: ./coverage 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - "5" 7 | 8 | branches: 9 | only: 10 | - master 11 | 12 | script: npm run travis 13 | 14 | before_install: 15 | - export CHROME_BIN=chromium-browser 16 | - export DISPLAY=:99.0 17 | - sh -e /etc/init.d/xvfb start 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Commit emojis 4 | 5 | * :hatching_chick: — initial commit 6 | * :sparkles: — new version 7 | * :recycle: — update dependencies 8 | * :heavy_check_mark: — bug fixed 9 | * :see_no_evil: — stupid bug fixed 10 | * :heavy_plus_sign: — new feature 11 | * :heavy_minus_sign: — remove feature/file/etc. 12 | * :wrench: — refactoring 13 | * :lipstick: — cosmetic refactoring 14 | * :space_invader: — tests 15 | * :pencil: — README/docs update 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Denis Koltsov 4 | Copyright (c) 2015 Kir Belevich 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | _ _ 3 | _| |___ ___| |_ 4 | | . | . | . | . | 5 | |___|___|___|___| 6 | ``` 7 | 8 | Smart immutable state for React 9 | --- 10 | 11 | [![maintenance](https://img.shields.io/badge/maintained-no-red.svg?style=flat-square)](http://unmaintained.tech) 12 | [![npm](https://img.shields.io/npm/v/doob.svg?style=flat-square)](https://www.npmjs.com/package/doob) 13 | [![downloads](https://img.shields.io/npm/dm/doob.svg?style=flat-square)](https://www.npmjs.com/package/doob) 14 | [![travis](http://img.shields.io/travis/mistadikay/doob.svg?style=flat-square)](https://travis-ci.org/mistadikay/doob) 15 | [![coverage](http://img.shields.io/coveralls/mistadikay/doob/master.svg?style=flat-square)](https://coveralls.io/r/mistadikay/doob) 16 | [![deps](http://img.shields.io/david/mistadikay/doob.svg?style=flat-square)](https://david-dm.org/mistadikay/doob) 17 | 18 | Basic concept is described in the article _[Declarative data fetching in React components with Baobab](https://medium.com/@mistadikay/declarative-data-fetching-in-react-components-with-baobab-e43184c43852)_ 19 | 20 | Example app using **doob** is here: https://github.com/mistadikay/react-auto-fetching-example/tree/doob 21 | 22 | ## Install 23 | ``` 24 | npm i doob 25 | ``` 26 | 27 | ## Modules 28 | Basically **doob** is just a [Baobab](https://github.com/Yomguithereal/baobab) tree on steroids with a few decorators for React components. 29 | 30 | ### State 31 | Before using **doob**, you should create the global state instance. This is the place where you put all the data. 32 | 33 | Both params are optional: 34 | * _**state**: object (default: `{}`)_ — initial data 35 | * _**options**: object (default: `undefined`)_ — tree options, the same as in [Baobab](https://github.com/Yomguithereal/baobab#options) 36 | 37 | ```js 38 | import { State } from 'doob'; 39 | 40 | new State({ 41 | test: 'hello' 42 | }); 43 | ``` 44 | 45 | ### DataInit 46 | This decorator should be wrapped around your root component. It accepts only one argument: your state instance. 47 | 48 | ```js 49 | import React from 'react'; 50 | import { State, DataInit } from 'doob'; 51 | 52 | const state = new State(); 53 | 54 | @DataInit(state) 55 | class App extends React.Component { 56 | // ... 57 | } 58 | ``` 59 | 60 | ### DataWatcher 61 | `DataWatcher` is a [higher-order component](https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750) that watches for changes in data dependencies you configured and then passes updated data to your component through props. 62 | 63 | It accepts only one argument — a function describing your data dependencies. In this function you can use your component's props ([but not local state](#local-state-in-data-dependencies)) as part of data dependencies, like this: 64 | 65 | ```js 66 | import React from 'react'; 67 | import { DataWatcher } from 'doob'; 68 | 69 | @DataWatcher(props => ({ 70 | productData: [ 71 | 'data', 72 | 'products', 73 | 'details', 74 | props.productID 75 | ] 76 | })) 77 | class Product extends React.Component { 78 | render() { 79 | const productData = this.props.productData; 80 | 81 | if (!productData) { 82 | return null; 83 | } 84 | 85 | return ( 86 |
87 | { productData.name } 88 |
89 | ); 90 | } 91 | } 92 | ``` 93 | 94 | You can even pass an object to data dependency path to filter data by certain field(s) (see [below](#using-objects-in-paths) to learn how to store data in this case): 95 | 96 | ```js 97 | @DataWatcher(props => ({ 98 | products: [ 99 | 'data', 100 | 'products', 101 | 'list', 102 | { 103 | search: props.search, 104 | sort_type: props.sortType 105 | } 106 | ] 107 | })) 108 | class ProductsList extends React.Component { 109 | // ... 110 | } 111 | ``` 112 | 113 | If you want to rely on another data path in your data dependency, you can do it like this: 114 | 115 | ```js 116 | @DataWatcher(props => ({ 117 | details: [ 118 | 'data', 119 | 'products', 120 | 'details', 121 | [ 122 | 'ui', 123 | 'products', 124 | 'selected' 125 | ] 126 | ] 127 | })) 128 | class Product extends React.Component { 129 | // ... 130 | } 131 | ``` 132 | 133 | There are few other props `DataWatcher` passes to it's child component. 134 | 135 | ### DataFetcher 136 | `DataFetcher` allows you to automate data requesting. Every time someone is trying to get data that is not exists yet, `DataFetcher` will look for a suitable `matcher` that you provided and calls its callback. Take a look at the example for a better understanding: 137 | 138 | ```js 139 | import React from 'react'; 140 | import { DataFetcher } from 'doob'; 141 | import productsActions from 'actions/products'; 142 | 143 | // DataFetcher receives an array of matcher factories as the only argument 144 | @DataFetcher([ 145 | // each matcher factory receives requested dependency path as an argument 146 | ([ type, branch, id, params ]) => [ 147 | // so when `[ 'data', 'products', 123, { sort_type: 'asc' } ]` is requested 148 | // `type` will be equal `'data'`, `branch` will be equal `'products'` and so on 149 | { 150 | // matcher calls its callback every time 151 | // when DataWatcher requests data dependency starting with matchers path 152 | path: [ 'data', 'products', id ], 153 | callback() { 154 | productsActions.getProducts(id, params); 155 | } 156 | }, 157 | ... 158 | ], 159 | ... 160 | ]) 161 | class App extends React.Component { 162 | // ... 163 | } 164 | ``` 165 | 166 | ### DataSender 167 | `DataSender` is an antipod of `DataFetcher` and it allows you to automate sending data to the server. Every time we change data in global state, `DataSender` will look for a suitable `matcher` that you provided and calls its callback. Take a look at the example for a better understanding: 168 | 169 | ```js 170 | import React from 'react'; 171 | import { DataSender } from 'doob'; 172 | import userActions from 'actions/user'; 173 | 174 | // The same matcher factories as we have in DataFetcher 175 | @DataSender([ 176 | ([ type, branch, field ]) => [ 177 | { 178 | // whenever we change username in a global state 179 | // it's sending to the server 180 | path: [ 'data', 'user', 'name' ], 181 | 182 | // the value from this path is available as an argument in callback 183 | callback(value) { 184 | userActions.saveUserName(value); 185 | } 186 | }, 187 | ... 188 | ], 189 | ... 190 | ]) 191 | class App extends React.Component { 192 | // ... 193 | } 194 | ``` 195 | 196 | ## Data flow 197 | 198 | ### local state in data dependencies 199 | As you may notice we do not allow using local component's state in `DataWatcher` data dependencies. Instead, you can just store this local state in the global one and change it through cursors: 200 | 201 | ```js 202 | //... 203 | 204 | @DataWatcher(props => ({ 205 | productData: [ 206 | 'data', 207 | 'products', 208 | { 209 | show_deleted: props.showDeleted 210 | } 211 | ], 212 | showDeleted: [ 213 | 'ui', 214 | 'products', 215 | 'show-deleted' 216 | ] 217 | })) 218 | class ProductsList extends React.Component { 219 | //... 220 | 221 | onCheckboxChange() { 222 | this.props.cursors.showDeleted.set(!props.showDeleted); 223 | } 224 | 225 | //... 226 | }; 227 | ``` 228 | 229 | If you think that local state should be supported in data dependencies path, drop us an issue and we'll discuss it. 230 | 231 | ### shouldComponentUpdate 232 | Since all the data you declared in `DataWatcher` is in props, you can use either `pureRenderMixin` or your custom `shouldComponentUpdate` to control when your component should be re-rendered. And since all the data in global state is immutable, you can compare props with `===`, including objects and arrays. 233 | 234 | ```js 235 | import React from 'react'; 236 | import { DataWatcher } from 'doob'; 237 | 238 | @DataWatcher(props => ({ 239 | product: [ 240 | 'data', 241 | 'products', 242 | 'details', 243 | props.productID 244 | ] 245 | })) 246 | class Product extends React.Component { 247 | shouldComponentUpdate(nextProps) { 248 | return nextProps.productID !== this.props.productID || nextProps.product !== this.props.product; 249 | } 250 | } 251 | ``` 252 | 253 | ### Fetching 254 | Keep in mind that since `DataFetcher` will fire callbacks everytime it doesn't find data, you might run into a problem with simultaneous identical requests (for example, requesting the same product id at the same time). You should take care about it yourself, for example, you can check whether there is any similar requests at the moment, like this: 255 | 256 | ```js 257 | function getProductInfo(productID) { 258 | if (!isRequesting[productID]) { 259 | isRequesting[productID] = true; 260 | 261 | requestProductInfo(productID); 262 | } 263 | } 264 | ``` 265 | 266 | ### Using objects in paths 267 | As we mentioned above, we can use objects as parts of data dependencies paths: 268 | 269 | ```js 270 | @DataWatcher(props => ({ 271 | products: [ 272 | 'data', 273 | 'products', 274 | 'list', 275 | { 276 | sort_type: props.sortType 277 | } 278 | ] 279 | })) 280 | ``` 281 | 282 | To be able to do that, we use Baobab's `select` method, so please take a look at how it works: https://github.com/Yomguithereal/baobab/wiki/Select-state 283 | 284 | So when you put data into the path, consider doing something like this: 285 | 286 | ```js 287 | // state instance 288 | import state from 'state'; 289 | 290 | state.getTree().select([ 'data', 'products', 'list' ]).apply( 291 | (products = []) => products.concat({ 292 | items: [ //...products ], 293 | sort_type: 'asc' 294 | }) 295 | ); 296 | ``` 297 | 298 | So, you'll have separate data chunks in `[ 'data', 'products', 'list' ]` for each `sort_type`. 299 | -------------------------------------------------------------------------------- /lib/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | init-declarations: 0 4 | -------------------------------------------------------------------------------- /lib/data-fetcher.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import State from './state'; 3 | import throwError from './throw-error'; 4 | 5 | module.exports = function(matchersFactories) { 6 | return function(Component) { 7 | class DataFetcher extends React.Component { 8 | static contextTypes = { 9 | state: React.PropTypes.instanceOf(State) 10 | }; 11 | 12 | constructor(props, context) { 13 | super(props, context); 14 | 15 | this.dataState = context.state; 16 | 17 | if (typeof this.dataState === 'undefined') { 18 | throwError(`missing state. It can happen if you forgot to set state in DataInit 19 | or using any decorator before DataInit`, this.constructor.displayName); 20 | } 21 | } 22 | 23 | componentDidMount() { 24 | this.dataState._registerFetcher(matchersFactories); 25 | } 26 | 27 | componentWillUnmount() { 28 | this.dataState._unregisterFetcher(matchersFactories); 29 | } 30 | 31 | render() { 32 | return React.createElement( 33 | Component, 34 | this.props, 35 | this.props.children 36 | ); 37 | } 38 | } 39 | 40 | return DataFetcher; 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /lib/data-init.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import State from './state'; 3 | 4 | module.exports = function(state) { 5 | return function(Component) { 6 | class DataInit extends React.Component { 7 | static childContextTypes = { 8 | state: React.PropTypes.instanceOf(State) 9 | }; 10 | 11 | constructor(props, context) { 12 | super(props, { 13 | ...context, 14 | state 15 | }); 16 | } 17 | 18 | getChildContext() { 19 | return { state }; 20 | } 21 | 22 | render() { 23 | return React.createElement( 24 | Component, 25 | this.props, 26 | this.props.children 27 | ); 28 | } 29 | } 30 | 31 | return DataInit; 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /lib/data-sender.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import State from './state'; 3 | import throwError from './throw-error'; 4 | 5 | module.exports = function(matchersFactories) { 6 | return function(Component) { 7 | class DataSender extends React.Component { 8 | static contextTypes = { 9 | state: React.PropTypes.instanceOf(State) 10 | }; 11 | 12 | constructor(props, context) { 13 | super(props, context); 14 | 15 | this.dataState = context.state; 16 | 17 | if (typeof this.dataState === 'undefined') { 18 | throwError(`missing state. It can happen if you forgot to set state in DataInit 19 | or using any decorator before DataInit`, this.constructor.displayName); 20 | } 21 | } 22 | 23 | componentDidMount() { 24 | this.dataState._registerSender(matchersFactories); 25 | } 26 | 27 | componentWillUnmount() { 28 | this.dataState._unregisterSender(matchersFactories); 29 | } 30 | 31 | render() { 32 | return React.createElement( 33 | Component, 34 | this.props, 35 | this.props.children 36 | ); 37 | } 38 | } 39 | 40 | return DataSender; 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /lib/data-watcher.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import State from './state'; 3 | import throwError from './throw-error'; 4 | 5 | module.exports = function(dataFactory) { 6 | return function(Component) { 7 | class DataWatcher extends React.Component { 8 | static contextTypes = { 9 | state: React.PropTypes.instanceOf(State) 10 | }; 11 | 12 | constructor(props, context) { 13 | super(props, context); 14 | 15 | this.cursors = {}; 16 | this.dataState = context.state; 17 | 18 | if (typeof this.dataState === 'undefined') { 19 | throwError(`missing state. It can happen if you forgot to set state in DataInit 20 | or using any decorator before DataInit`, this.constructor.displayName); 21 | } 22 | 23 | this._initCursors(props); 24 | 25 | this.state = { 26 | ...this.state, 27 | data: this._getCurrentData() 28 | }; 29 | 30 | this._updateDataState = this._updateDataState.bind(this); 31 | } 32 | 33 | componentDidMount() { 34 | Object.keys(this.cursors).forEach(branch => { 35 | this.dataState._addToWatchingQueue(this.cursors[branch].path); 36 | }); 37 | 38 | this._updateDataState(); 39 | this._dataWatch(); 40 | } 41 | 42 | componentWillReceiveProps(nextProps) { 43 | if (this._isChangedProps(nextProps)) { 44 | this._reloadComponentData(nextProps); 45 | } 46 | } 47 | 48 | componentWillUnmount() { 49 | Object.keys(this.cursors).forEach(branch => { 50 | this.dataState._removeFromWatchingQueue(this.cursors[branch].path); 51 | }); 52 | 53 | this._dataUnwatch(); 54 | } 55 | 56 | _isChangedProps(nextProps) { 57 | return Object.keys(nextProps).some(key => nextProps[key] !== this.props[key]); 58 | } 59 | 60 | _reloadComponentData(props = this.props) { 61 | this._dataUnwatch(); 62 | this._initCursors(props); 63 | this._updateDataState(); 64 | this._dataWatch(); 65 | } 66 | 67 | _reloadComponentDataFromPath() { 68 | this._reloadComponentData(); 69 | } 70 | 71 | _isValidObjectPathChunk(pathChunk) { 72 | return Object.keys(pathChunk).every(key => this._isValidPathChunk(pathChunk[key])); 73 | } 74 | 75 | _isValidPathChunk(pathChunk) { 76 | return typeof pathChunk !== 'undefined'; 77 | } 78 | 79 | _isValidPath(path) { 80 | return path.length !== 0 && path.every(pathChunk => { 81 | if (typeof pathChunk === 'object') { 82 | return this._isValidObjectPathChunk(pathChunk); 83 | } 84 | 85 | return this._isValidPathChunk(pathChunk); 86 | }); 87 | } 88 | 89 | _prepareArrayPathChunk(pathChunk) { 90 | const stateTree = this.dataState.getTree(); 91 | const preparedCursorPath = this._prepareCursorPath(pathChunk); 92 | 93 | if (!this._isValidPath(preparedCursorPath)) { 94 | return; 95 | } 96 | 97 | const pathChunkCursor = stateTree.select(preparedCursorPath); 98 | const shouldListenForUpdate = pathChunkCursor.listeners('update').every( 99 | listener => { 100 | return listener.scope !== this; 101 | } 102 | ); 103 | 104 | if (shouldListenForUpdate) { 105 | pathChunkCursor.on( 106 | 'update', 107 | this._reloadComponentDataFromPath, 108 | { 109 | scope: this 110 | } 111 | ); 112 | } 113 | 114 | return pathChunkCursor.get(); 115 | } 116 | 117 | _prepareObjectPathChunk(pathChunk) { 118 | const preparedPathChunk = {}; 119 | 120 | Object.keys(pathChunk).forEach(key => { 121 | preparedPathChunk[key] = this._preparePathChunk(pathChunk[key]); 122 | }); 123 | 124 | return preparedPathChunk; 125 | } 126 | 127 | _preparePathChunk(pathChunk) { 128 | if (Array.isArray(pathChunk)) { 129 | return this._prepareArrayPathChunk(pathChunk); 130 | } else if (typeof pathChunk === 'object') { 131 | return this._prepareObjectPathChunk(pathChunk); 132 | } 133 | 134 | return pathChunk; 135 | } 136 | 137 | _prepareCursorPath(path) { 138 | return path.map(::this._preparePathChunk); 139 | } 140 | 141 | _prepareCursorPaths(props) { 142 | const cursorPaths = dataFactory(props); 143 | 144 | Object.keys(cursorPaths).forEach(branch => { 145 | cursorPaths[branch] = this._prepareCursorPath(cursorPaths[branch]); 146 | }); 147 | 148 | return cursorPaths; 149 | } 150 | 151 | _initCursors(props) { 152 | const stateTree = this.dataState.getTree(); 153 | 154 | this.cursorPaths = this._prepareCursorPaths(props); 155 | 156 | Object.keys(this.cursorPaths) 157 | .filter(branch => this._isValidPath(this.cursorPaths[branch])) 158 | .forEach(branch => { 159 | this.cursors[branch] = stateTree.select(this.cursorPaths[branch]); 160 | }); 161 | } 162 | 163 | _dataWatch() { 164 | Object.keys(this.cursors).forEach( 165 | cursorType => this.cursors[cursorType].on('update', this._updateDataState) 166 | ); 167 | } 168 | 169 | _dataUnwatch() { 170 | Object.keys(this.cursors).forEach( 171 | cursorType => this.cursors[cursorType].off('update', this._updateDataState) 172 | ); 173 | } 174 | 175 | _getCurrentData() { 176 | const data = {}; 177 | 178 | Object.keys(this.cursors).forEach(branch => { 179 | data[branch] = this.cursors[branch].get(); 180 | }); 181 | 182 | return data; 183 | } 184 | 185 | _updateDataState() { 186 | this.setState({ 187 | data: this._getCurrentData() 188 | }); 189 | } 190 | 191 | render() { 192 | return React.createElement( 193 | Component, 194 | { 195 | ...this.props, 196 | ...this.state.data 197 | }, 198 | this.props.children 199 | ); 200 | } 201 | } 202 | 203 | return DataWatcher; 204 | }; 205 | }; 206 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | export DataWatcher from './data-watcher'; 2 | export DataFetcher from './data-fetcher'; 3 | export DataSender from './data-sender'; 4 | export DataInit from './data-init'; 5 | export State from './state'; 6 | -------------------------------------------------------------------------------- /lib/state.js: -------------------------------------------------------------------------------- 1 | import Baobab from 'baobab'; 2 | import isEqual from 'lodash.isequal'; 3 | 4 | module.exports = class State { 5 | constructor(initialState = {}, options) { 6 | this._tree = new Baobab(initialState, options); 7 | this._watchingQueue = []; 8 | this._watchingPaths = []; 9 | this._fetchers = []; 10 | this._senders = []; 11 | 12 | this._onPathGet = this._onPathGet.bind(this); 13 | this._onPathWrite = this._onPathWrite.bind(this); 14 | } 15 | 16 | /** 17 | * Fetchers API 18 | */ 19 | _onPathGet(e) { 20 | const path = e.data.path; 21 | 22 | this._fetchers.forEach(this._processFetcherWithPath.bind(this, path)); 23 | } 24 | 25 | _isPathMatchedBy(cursorPath, matchersFactories) { 26 | return matchersFactories.some(matchersFactory => { 27 | return matchersFactory(cursorPath).some(matcher => { 28 | return matcher.path.every((chunk, i) => { 29 | return isEqual(chunk, cursorPath[i]); 30 | }); 31 | }); 32 | }); 33 | } 34 | 35 | _isPathMatched(cursorPath) { 36 | return this._fetchers.some(this._isPathMatchedBy.bind(this, cursorPath)); 37 | } 38 | 39 | _processFetcherWithPath(cursorPath, matchersFactories) { 40 | const isDataExists = this.exists(cursorPath); 41 | 42 | matchersFactories.forEach(matchersFactory => { 43 | matchersFactory(cursorPath).forEach(matcher => { 44 | const matched = matcher.path.every((chunk, i) => { 45 | return chunk === cursorPath[i]; 46 | }); 47 | 48 | // if cursor path is matched by some fetcher and 49 | // such data is not exists yet 50 | if (matched && !isDataExists) { 51 | matcher.callback(); 52 | } 53 | }); 54 | }); 55 | } 56 | 57 | _registerFetcher(fetcher) { 58 | // add `get`-listener if there is no fetchers yet 59 | if (this._fetchers.length === 0) { 60 | this._tree.on('get', this._onPathGet); 61 | } 62 | 63 | // move matched by new fetcher paths from queue 64 | this._watchingQueue = this._watchingQueue.filter(queuePath => { 65 | if (this._isPathMatchedBy(queuePath, fetcher)) { 66 | this._watchingPaths.push(queuePath); 67 | 68 | return false; 69 | } 70 | 71 | return true; 72 | }); 73 | 74 | // process all watching paths with the new fetcher 75 | this._watchingPaths.forEach(watchingPath => { 76 | this._processFetcherWithPath(watchingPath, fetcher); 77 | }); 78 | 79 | // store fetcher 80 | this._fetchers.push(fetcher); 81 | } 82 | 83 | _unregisterFetcher(fetcher) { 84 | // remove fetcher from registered fetchers 85 | this._fetchers = this._fetchers.filter(registeredFetcher => { 86 | return registeredFetcher !== fetcher; 87 | }); 88 | 89 | // move watched by nobody paths to queue 90 | this._watchingPaths = this._watchingPaths.filter(watchingPath => { 91 | if (!this._isPathMatched(watchingPath)) { 92 | this._watchingQueue.push(watchingPath); 93 | 94 | return false; 95 | } 96 | 97 | return true; 98 | }); 99 | 100 | // remove `get`-listener if there is no fetchers anymore 101 | if (this._fetchers.length === 0) { 102 | this._tree.off('get', this._onPathGet); 103 | } 104 | } 105 | 106 | _addToWatchingQueue(cursorPath) { 107 | // only if path is not matched by registered fetchers 108 | if (this._isPathMatched(cursorPath)) { 109 | this._watchingPaths.push(cursorPath); 110 | } else { 111 | this._watchingQueue.push(cursorPath); 112 | } 113 | } 114 | 115 | _removeFromWatchingQueue(cursorPath) { 116 | // remove from watching paths if path is matched by any registered fetchers 117 | if (this._isPathMatched(cursorPath)) { 118 | this._watchingPaths.splice(this._watchingPaths.indexOf(cursorPath), 1); 119 | 120 | // otherwise remove from queue 121 | } else { 122 | this._watchingQueue.splice(this._watchingQueue.indexOf(cursorPath), 1); 123 | } 124 | } 125 | 126 | /** 127 | * Senders API 128 | */ 129 | _onPathWrite(e) { 130 | const path = e.data.path; 131 | 132 | this._senders.forEach(this._processSenderWithPath.bind(this, path)); 133 | } 134 | 135 | _processSenderWithPath(cursorPath, matchersFactories) { 136 | const isDataExists = this.exists(cursorPath); 137 | 138 | // it's intentionally undefined by default 139 | // because Baobab returns undefined if there is no value in path 140 | let value; 141 | 142 | // we retrieve value only if it is exists 143 | // so DataFetcher wouldn't trigger when we don't want to 144 | if (isDataExists) { 145 | value = this.getIn(cursorPath); 146 | } 147 | 148 | matchersFactories.forEach(matchersFactory => { 149 | matchersFactory(cursorPath).forEach(matcher => { 150 | const matched = matcher.path.every((chunk, i) => { 151 | return chunk === cursorPath[i]; 152 | }); 153 | 154 | // trigger callback if path is matched 155 | if (matched) { 156 | matcher.callback(value); 157 | } 158 | }); 159 | }); 160 | } 161 | 162 | _registerSender(sender) { 163 | // add `write`-listener if there is no senders yet 164 | if (this._senders.length === 0) { 165 | this._tree.on('write', this._onPathWrite); 166 | } 167 | 168 | // store sender 169 | this._senders.push(sender); 170 | } 171 | 172 | _unregisterSender(sender) { 173 | // remove sender from registered senders 174 | this._senders = this._senders.filter(registeredSender => { 175 | return registeredSender !== sender; 176 | }); 177 | 178 | // remove `write`-listener if there is no senders anymore 179 | if (this._senders.length === 0) { 180 | this._tree.off('write', this._onPathWrite); 181 | } 182 | } 183 | 184 | /** 185 | * Public API 186 | */ 187 | getTree() { 188 | return this._tree; 189 | } 190 | 191 | get() { 192 | return this._tree.get(); 193 | } 194 | 195 | getIn(cursorPath) { 196 | return this._tree.get(cursorPath); 197 | } 198 | 199 | set(data) { 200 | this._tree.set(data); 201 | } 202 | 203 | setIn(cursorPath, data) { 204 | this._tree.set(cursorPath, data); 205 | } 206 | 207 | unset() { 208 | this._tree.unset(); 209 | } 210 | 211 | unsetIn(cursorPath) { 212 | this._tree.unset(cursorPath); 213 | } 214 | 215 | exists(path) { 216 | return this._tree.select(path).exists(); 217 | } 218 | }; 219 | -------------------------------------------------------------------------------- /lib/throw-error.js: -------------------------------------------------------------------------------- 1 | module.exports = function(msg, at) { 2 | throw new Error(`doob, ${at || 'unknown'} component: ${msg}`); 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doob", 3 | "version": "2.0.1", 4 | "description": "Smart immutable state for React", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "state", 9 | "immutable", 10 | "flux", 11 | "data" 12 | ], 13 | "homepage": "https://github.com/mistadikay/doob#readme", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/mistadikay/doob.git" 17 | }, 18 | "contributors": [ 19 | "Denis Koltsov (https://github.com/mistadikay)", 20 | "Kir Belevich (https://github.com/deepsweet)" 21 | ], 22 | "main": "build/index.js", 23 | "files": [ 24 | "build/", 25 | "LICENSE" 26 | ], 27 | "peerDependencies": { 28 | "react": ">=0.14.0 <0.16.0" 29 | }, 30 | "dependencies": { 31 | "babel-runtime": "^6.3.19", 32 | "baobab": "^2.0.0", 33 | "lodash.isequal": "^3.0.0" 34 | }, 35 | "devDependencies": { 36 | "react": "0.14.x", 37 | "react-dom": "0.14.x", 38 | "react-addons-test-utils": "0.14.x", 39 | 40 | "husky": "0.10.x", 41 | "rimraf": "2.5.x", 42 | 43 | "babel-core": "6.3.x", 44 | "babel-cli": "6.3.x", 45 | "babel-preset-es2015": "6.3.x", 46 | "babel-preset-stage-0": "6.3.x", 47 | "babel-preset-react": "6.3.x", 48 | "babel-plugin-transform-runtime": "6.3.x", 49 | "babel-plugin-transform-decorators-legacy": "1.3.x", 50 | 51 | "webpack": "1.12.x", 52 | "webpack-dev-server": "1.14.x", 53 | "babel-loader": "6.2.x", 54 | "isparta-loader": "2.0.x", 55 | 56 | "mocha": "2.3.x", 57 | "chai": "3.4.x", 58 | "chai-spies": "0.7.x", 59 | "coveralls": "2.11.x", 60 | 61 | "karma": "0.13.x", 62 | "karma-mocha": "0.2.x", 63 | "karma-mocha-reporter": "1.1.x", 64 | "karma-chrome-launcher": "0.2.x", 65 | "karma-firefox-launcher": "0.1.x", 66 | "karma-webpack": "1.7.x", 67 | "karma-coverage": "0.5.x", 68 | "karma-clear-screen-reporter": "1.0.x", 69 | 70 | "eslint": "1.10.x", 71 | "eslint-plugin-babel": "3.0.x", 72 | "eslint-plugin-react": "3.12.x", 73 | "babel-eslint": ">5.0.0-beta1" 74 | }, 75 | "scripts": { 76 | "prebuild": "rimraf build/", 77 | "build": "babel lib/ -d build/", 78 | 79 | "dev": "npm run build -- -w", 80 | "tdd": "npm run karma start test/karma.dev.js", 81 | 82 | "lint": "eslint lib/ test/", 83 | 84 | "prekarma": "rimraf coverage/", 85 | "karma": "babel-node ./node_modules/.bin/karma", 86 | 87 | "precoveralls": "npm run karma start test/karma.travis.js", 88 | "coveralls": "coveralls < coverage/lcov.info", 89 | 90 | "pretravis": "npm run lint", 91 | "travis": "npm run coveralls", 92 | 93 | "pretest": "npm run lint", 94 | "test": "npm run karma start test/karma.build.js", 95 | 96 | "prepush": "npm test", 97 | "prepublish": "npm run build" 98 | }, 99 | "bugs": { 100 | "url": "https://github.com/mistadikay/doob/issues" 101 | }, 102 | "engines": { 103 | "node": ">=0.12.0", 104 | "npm": ">=2.7.0" 105 | }, 106 | "license": "MIT" 107 | } 108 | -------------------------------------------------------------------------------- /test/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "stage-0", "react" ], 3 | "plugins": [ "transform-runtime", "transform-decorators-legacy" ] 4 | } 5 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | plugins: 3 | - react 4 | 5 | env: 6 | mocha: true 7 | 8 | rules: 9 | no-new: 1 10 | no-magic-numbers: 0 11 | -------------------------------------------------------------------------------- /test/helpers/render.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import TestUtils from 'react-addons-test-utils'; 4 | 5 | export function render(component, props, ...children) { 6 | return TestUtils.renderIntoDocument( 7 | React.createElement(component, props, ...children) 8 | ); 9 | } 10 | 11 | export function getRenderedDOM(...args) { 12 | return ReactDOM.findDOMNode( 13 | render(...args) 14 | ); 15 | } 16 | 17 | export function createShallowRender() { 18 | const shallowRenderer = TestUtils.createRenderer(); 19 | 20 | return function(component, props, ...children) { 21 | shallowRenderer.render( 22 | React.createElement( 23 | component, 24 | props, 25 | ...children 26 | ) 27 | ); 28 | 29 | return shallowRenderer.getRenderOutput(); 30 | }; 31 | } 32 | 33 | export function shallowRender(...args) { 34 | return createShallowRender()(...args); 35 | } 36 | -------------------------------------------------------------------------------- /test/karma.build.js: -------------------------------------------------------------------------------- 1 | import karmaCommon from './karma.common'; 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | ...karmaCommon, 6 | singleRun: true, 7 | logLevel: config.LOG_DISABLE, 8 | reporters: [ 'mocha', 'coverage' ], 9 | coverageReporter: { 10 | ...karmaCommon.coverageReporter, 11 | reporters: [ 12 | ...karmaCommon.coverageReporter.reporters, 13 | { 14 | type: 'html' 15 | }, 16 | { 17 | type: 'text' 18 | }, 19 | { 20 | type: 'text-summary' 21 | } 22 | ] 23 | }, 24 | customLaunchers: { 25 | ChromeCustom: { 26 | base: 'Chrome', 27 | flags: [ 28 | '--window-size=0,0', 29 | '--window-position=0,0' 30 | ] 31 | } 32 | }, 33 | browsers: [ 'ChromeCustom' ] 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /test/karma.common.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export default { 4 | colors: true, 5 | files: [ 6 | 'lib/*.js' 7 | ], 8 | preprocessors: { 9 | 'lib/*.js': 'webpack' 10 | }, 11 | frameworks: [ 'mocha' ], 12 | webpack: { 13 | cache: true, 14 | resolve: { 15 | root: path.resolve('./') 16 | }, 17 | module: { 18 | preLoaders: [ 19 | { 20 | test: /\.js$/, 21 | exclude: [ 22 | path.resolve('lib/'), 23 | path.resolve('node_modules/') 24 | ], 25 | loader: 'babel' 26 | }, 27 | { 28 | test: /\.js$/, 29 | include: path.resolve('lib/'), 30 | loader: 'isparta' 31 | } 32 | ] 33 | } 34 | }, 35 | webpackMiddleware: { 36 | noInfo: true, 37 | quiet: true 38 | }, 39 | coverageReporter: { 40 | dir: '../coverage', 41 | reporters: [ 42 | { type: 'lcovonly', subdir: '.' } 43 | ] 44 | }, 45 | browserNoActivityTimeout: 60 * 1000, // default 10 * 1000 46 | browserDisconnectTimeout: 10 * 1000, // default 2 * 1000 47 | browserDisconnectTolerance: 2, // default 0 48 | captureTimeout: 2 * 60 * 1000 // default 1 * 60 * 1000 49 | }; 50 | -------------------------------------------------------------------------------- /test/karma.dev.js: -------------------------------------------------------------------------------- 1 | import karmaCommon from './karma.common'; 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | ...karmaCommon, 6 | logLevel: config.LOG_INFO, 7 | reporters: [ 'clear-screen', 'mocha', 'coverage' ], 8 | coverageReporter: { 9 | ...karmaCommon.coverageReporter, 10 | reporters: [ 11 | ...karmaCommon.coverageReporter.reporters, 12 | { 13 | type: 'html' 14 | }, 15 | { 16 | type: 'text' 17 | }, 18 | { 19 | type: 'text-summary' 20 | } 21 | ] 22 | }, 23 | customLaunchers: { 24 | ChromeCustom: { 25 | base: 'Chrome', 26 | flags: [ 27 | '--window-size=0,0', 28 | '--window-position=0,0' 29 | ] 30 | } 31 | }, 32 | browsers: [ 'ChromeCustom' ] 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /test/karma.travis.js: -------------------------------------------------------------------------------- 1 | import karmaCommon from './karma.common'; 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | ...karmaCommon, 6 | singleRun: true, 7 | logLevel: config.LOG_INFO, 8 | reporters: [ 'dots', 'coverage' ], 9 | customLaunchers: { 10 | ChromeTravis: { 11 | base: 'Chrome', 12 | flags: [ '--no-sandbox' ] 13 | } 14 | }, 15 | browsers: [ 'ChromeTravis', 'Firefox' ] 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /test/lib/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | no-catch-shadow: 0 4 | no-undefined: 0 5 | no-invalid-this: 0 6 | max-nested-callbacks: 7 | - 1 8 | - 10 9 | -------------------------------------------------------------------------------- /test/lib/data-fetcher.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import spies from 'chai-spies'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import { getRenderedDOM } from 'test/helpers/render'; 7 | 8 | import State from 'lib/state'; 9 | import DataInit from 'lib/data-init'; 10 | import DataFetcher from 'lib/data-fetcher'; 11 | import DataWatcher from 'lib/data-watcher'; 12 | 13 | chai.use(spies); 14 | 15 | describe('data-fetcher', function() { 16 | beforeEach(function() { 17 | const firstPath = [ 'test', 'one' ]; 18 | const secondPath = [ 'test', 'two' ]; 19 | const thirdPath = [ 'test', 'three' ]; 20 | const fourthPath = [ 'test', 'four' ]; 21 | 22 | this.firstPath = firstPath; 23 | this.secondPath = secondPath; 24 | this.thirdPath = thirdPath; 25 | this.fourthPath = fourthPath; 26 | 27 | this.state = new State({ 28 | shouldBeUnmounted: false, 29 | test: { 30 | modest: 'hi' 31 | } 32 | }, { 33 | asynchronous: false 34 | }); 35 | 36 | this.callback = chai.spy(function() {}); 37 | 38 | // component 39 | class Component extends React.Component { 40 | render() { 41 | return ( 42 |
43 | { this.props.test } 44 | { this.props.two } 45 | { this.props.three } 46 | { this.props.four } 47 |
48 | ); 49 | } 50 | } 51 | 52 | this.Component = DataWatcher(() => ({ 53 | one: firstPath, 54 | two: secondPath, 55 | three: thirdPath, 56 | four: fourthPath 57 | }))(Component); 58 | const PatchedComponent = this.Component; 59 | 60 | // parent 61 | class ParentComponent extends React.Component { 62 | render() { 63 | return ( 64 | 65 | ); 66 | } 67 | } 68 | this.ParentComponent = DataFetcher([ 69 | () => [ 70 | { 71 | path: firstPath, 72 | callback: this.callback 73 | }, 74 | { 75 | path: thirdPath, 76 | callback: this.callback 77 | } 78 | ] 79 | ])(ParentComponent); 80 | const PatchedParentComponent = this.ParentComponent; 81 | 82 | // parent2 83 | class ParentComponent2 extends React.Component { 84 | render() { 85 | return ( 86 | 87 | ); 88 | } 89 | } 90 | this.ParentComponent2 = DataFetcher([ 91 | () => [ 92 | { 93 | path: secondPath, 94 | callback: this.callback 95 | }, 96 | { 97 | path: thirdPath, 98 | callback: this.callback 99 | } 100 | ] 101 | ])(ParentComponent2); 102 | const PatchedParentComponent2 = this.ParentComponent2; 103 | 104 | class AppComponent extends React.Component { 105 | _renderParent() { 106 | if (!this.props.shouldBeUnmounted) { 107 | return ; 108 | } 109 | 110 | return null; 111 | } 112 | 113 | render() { 114 | return ( 115 |
116 | { this._renderParent() } 117 | 118 |
119 | ); 120 | } 121 | } 122 | 123 | this.renderApp = function(state) { 124 | return getRenderedDOM( 125 | DataInit(state)( 126 | DataWatcher(() => ({ 127 | shouldBeUnmounted: [ 'shouldBeUnmounted' ] 128 | }))(AppComponent) 129 | ) 130 | ); 131 | }; 132 | 133 | this.render = function(state) { 134 | return getRenderedDOM( 135 | DataInit(state)( 136 | DataFetcher([ 137 | () => [ 138 | { 139 | path: firstPath, 140 | callback: this.callback 141 | }, 142 | { 143 | path: thirdPath, 144 | callback: this.callback 145 | } 146 | ] 147 | ])(this.Component) 148 | ) 149 | ); 150 | }; 151 | }); 152 | 153 | it('exists', function() { 154 | expect(DataFetcher).to.exist; 155 | }); 156 | 157 | it('should throw error when missing state', function() { 158 | try { 159 | this.render(); 160 | } catch (e) { 161 | expect(this.render).to.throw(Error); 162 | } 163 | }); 164 | 165 | it('should be registered in state on mount', function() { 166 | this.render(this.state); 167 | this.render(this.state); 168 | this.render(this.state); 169 | 170 | expect(this.state._fetchers.length).to.be.equal(3); 171 | }); 172 | 173 | it('should be unregistered from state when unmounted', function() { 174 | const mountedComponent = this.render(this.state); 175 | 176 | ReactDOM.unmountComponentAtNode(mountedComponent.parentNode); 177 | 178 | expect(this.state._fetchers.length).to.be.equal(0); 179 | }); 180 | 181 | it('should call fetcher when mounted', function() { 182 | this.render(this.state); 183 | 184 | expect(this.callback).to.be.called(); 185 | }); 186 | 187 | it('should process correctly DataWatcher paths when mounted', function() { 188 | this.render(this.state); 189 | 190 | expect(this.state._watchingQueue).to.have.length(2); 191 | expect(this.state._watchingPaths).to.have.length(2); 192 | expect(this.state._watchingQueue[0]).to.be.deep.equal(this.secondPath); 193 | expect(this.state._watchingPaths[0]).to.be.deep.equal(this.firstPath); 194 | }); 195 | 196 | it('should process correctly DataWatcher paths when unmounted', function() { 197 | const mountedComponent = this.render(this.state); 198 | 199 | ReactDOM.unmountComponentAtNode(mountedComponent.parentNode); 200 | 201 | expect(this.state._watchingQueue).to.have.length(0); 202 | expect(this.state._watchingPaths).to.have.length(0); 203 | }); 204 | 205 | it('should process correctly DataWatcher paths when one of DataFetchers unmounted', function() { 206 | this.renderApp(this.state); 207 | this.state.setIn([ 'shouldBeUnmounted' ], true); 208 | 209 | expect(this.state._watchingQueue).to.have.length(3); 210 | expect(this.state._watchingPaths).to.have.length(2); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /test/lib/data-init.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import React from 'react'; 3 | 4 | import { render } from 'test/helpers/render'; 5 | 6 | import State from 'lib/state'; 7 | import DataInit from 'lib/data-init'; 8 | 9 | describe('data-init', function() { 10 | it('exists', function() { 11 | expect(DataInit).to.exist; 12 | }); 13 | 14 | it('should pass state context to the children', function() { 15 | let parentState = null; 16 | let childState = null; 17 | 18 | class Component extends React.Component { 19 | static contextTypes = { 20 | state: React.PropTypes.instanceOf(State) 21 | }; 22 | 23 | constructor(props, context) { 24 | super(props, context); 25 | 26 | childState = context.state; 27 | } 28 | 29 | render() { 30 | return ( 31 |
32 | ); 33 | } 34 | } 35 | 36 | const state = new State(); 37 | 38 | @DataInit(state) 39 | class WrapperComponent extends React.Component { 40 | static contextTypes = { 41 | state: React.PropTypes.instanceOf(State) 42 | }; 43 | 44 | constructor(props, context) { 45 | super(props, context); 46 | 47 | parentState = context.state; 48 | } 49 | 50 | render() { 51 | return ( 52 |
53 | { React.createElement(Component) } 54 |
55 | ); 56 | } 57 | } 58 | 59 | render(WrapperComponent); 60 | 61 | expect(parentState).to.be.an.instanceof(State); 62 | expect(childState).to.be.an.instanceof(State); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/lib/data-sender.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import spies from 'chai-spies'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import { getRenderedDOM } from 'test/helpers/render'; 7 | 8 | import State from 'lib/state'; 9 | import DataInit from 'lib/data-init'; 10 | import DataSender from 'lib/data-sender'; 11 | import DataWatcher from 'lib/data-watcher'; 12 | 13 | chai.use(spies); 14 | 15 | describe('data-sender', function() { 16 | beforeEach(function() { 17 | const firstPath = [ 'test', 'one' ]; 18 | const secondPath = [ 'test', 'two' ]; 19 | const thirdPath = [ 'test', 'three' ]; 20 | 21 | this.state = new State({ 22 | shouldBeUnmounted: false, 23 | test: { 24 | } 25 | }, { 26 | asynchronous: false 27 | }); 28 | 29 | this.callback = chai.spy(function() {}); 30 | 31 | class Component extends React.Component { 32 | render() { 33 | return ( 34 |
35 | ); 36 | } 37 | } 38 | this.Component = Component; 39 | 40 | // parent 41 | class ParentComponent extends React.Component { 42 | render() { 43 | return ( 44 | 45 | ); 46 | } 47 | } 48 | this.ParentComponent = DataSender([ 49 | () => [ 50 | { 51 | path: firstPath, 52 | callback: this.callback 53 | }, 54 | { 55 | path: thirdPath, 56 | callback: this.callback 57 | } 58 | ] 59 | ])(ParentComponent); 60 | const PatchedParentComponent = this.ParentComponent; 61 | 62 | // parent2 63 | class ParentComponent2 extends React.Component { 64 | render() { 65 | return ( 66 | 67 | ); 68 | } 69 | } 70 | this.ParentComponent2 = DataSender([ 71 | () => [ 72 | { 73 | path: secondPath, 74 | callback: this.callback 75 | }, 76 | { 77 | path: thirdPath, 78 | callback: this.callback 79 | } 80 | ] 81 | ])(ParentComponent2); 82 | const PatchedParentComponent2 = this.ParentComponent2; 83 | 84 | class AppComponent extends React.Component { 85 | _renderParent() { 86 | if (!this.props.shouldBeUnmounted) { 87 | return ; 88 | } 89 | 90 | return null; 91 | } 92 | 93 | render() { 94 | return ( 95 |
96 | { this._renderParent() } 97 | 98 |
99 | ); 100 | } 101 | } 102 | 103 | this.renderApp = function(state) { 104 | return getRenderedDOM( 105 | DataInit(state)( 106 | DataWatcher(() => ({ 107 | shouldBeUnmounted: [ 'shouldBeUnmounted' ] 108 | }))(AppComponent) 109 | ) 110 | ); 111 | }; 112 | 113 | this.render = function(state) { 114 | return getRenderedDOM( 115 | DataInit(state)( 116 | DataSender([ 117 | () => [ 118 | { 119 | path: firstPath, 120 | callback: this.callback 121 | } 122 | ] 123 | ])(this.Component) 124 | ) 125 | ); 126 | }; 127 | }); 128 | 129 | it('exists', function() { 130 | expect(DataSender).to.exist; 131 | }); 132 | 133 | it('should throw error when missing state', function() { 134 | try { 135 | this.render(); 136 | } catch (e) { 137 | expect(this.render).to.throw(Error); 138 | } 139 | }); 140 | 141 | it('should be registered in state on mount', function() { 142 | this.render(this.state); 143 | this.render(this.state); 144 | this.render(this.state); 145 | 146 | expect(this.state._senders.length).to.be.equal(3); 147 | }); 148 | 149 | it('should be unregistered from state when unmounted', function() { 150 | const mountedComponent = this.render(this.state); 151 | 152 | ReactDOM.unmountComponentAtNode(mountedComponent.parentNode); 153 | 154 | expect(this.state._senders.length).to.be.equal(0); 155 | expect(this.state._tree.listeners('write').length).to.be.equal(0); 156 | }); 157 | 158 | it('should remain write callback when one of the senders unmounted', function() { 159 | this.renderApp(this.state); 160 | this.state.setIn([ 'shouldBeUnmounted' ], true); 161 | 162 | expect(this.state._tree.listeners('write').length).to.be.equal(1); 163 | }); 164 | 165 | it('should not call callback when data changed on different path', function() { 166 | this.render(this.state); 167 | this.state.setIn([ 'hello' ], 'test'); 168 | 169 | expect(this.callback).to.not.have.been.called.once; 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/lib/data-watcher.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import spies from 'chai-spies'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import { getRenderedDOM } from 'test/helpers/render'; 7 | 8 | import State from 'lib/state'; 9 | import DataInit from 'lib/data-init'; 10 | import DataWatcher from 'lib/data-watcher'; 11 | 12 | chai.use(spies); 13 | 14 | describe('data-watcher', function() { 15 | it('exists', function() { 16 | expect(DataWatcher).to.exist; 17 | }); 18 | 19 | it('should throw error when missing state', function() { 20 | class Component extends React.Component { 21 | render() { 22 | return ( 23 |
24 | ); 25 | } 26 | } 27 | 28 | function render() { 29 | return getRenderedDOM( 30 | DataInit()( 31 | DataWatcher( 32 | () => ({ 33 | parentProp: [ 'parentStuff' ] 34 | }) 35 | )(Component) 36 | ) 37 | ); 38 | } 39 | 40 | try { 41 | render(); 42 | } catch (e) { 43 | expect(render).to.throw(Error); 44 | } 45 | }); 46 | 47 | describe('should update data', function() { 48 | describe('when using object as part of the path', function() { 49 | beforeEach(function() { 50 | const propsSpy = chai.spy(function() {}); 51 | 52 | this.propsSpy = propsSpy; 53 | 54 | class Component extends React.Component { 55 | render() { 56 | Object.keys(this.props).forEach(key => { 57 | if (this.props[key]) { 58 | propsSpy(this.props[key].value); 59 | } 60 | }); 61 | 62 | return null; 63 | } 64 | } 65 | 66 | this.state = new State({ 67 | one: [ 68 | { 69 | filter: 'yourmom', 70 | value: 'test' 71 | } 72 | ], 73 | two: [ 74 | { 75 | filter: 'wtf', 76 | value: 'test2' 77 | } 78 | ] 79 | }, { 80 | asynchronous: false 81 | }); 82 | 83 | this.dataFactory = chai.spy(() => { 84 | return { 85 | one: [ 86 | 'one', 87 | { 88 | filter: 'yourmom' 89 | } 90 | ], 91 | two: [ 92 | 'two', 93 | { 94 | filter: 'wtf' 95 | } 96 | ] 97 | }; 98 | }); 99 | 100 | this.renderMock = function(props) { 101 | return getRenderedDOM( 102 | DataInit(this.state)( 103 | DataWatcher(this.dataFactory)(Component) 104 | ), 105 | props 106 | ); 107 | }; 108 | }); 109 | 110 | it('simple', function() { 111 | this.renderMock(); 112 | 113 | expect(this.propsSpy).to.be.called.with('test'); 114 | expect(this.propsSpy).to.be.called.with('test2'); 115 | }); 116 | }); 117 | 118 | describe('when using nested dependencies', function() { 119 | beforeEach(function() { 120 | const propsSpy = chai.spy(function() {}); 121 | const nestedPath = [ 'foo', 'bar' ]; 122 | const nestedNestedPath = [ 'foo', 'bar', [ 'nested' ] ]; 123 | 124 | this.propsSpy = propsSpy; 125 | 126 | class Component extends React.Component { 127 | render() { 128 | Object.keys(this.props).forEach(key => { 129 | propsSpy(this.props[key]); 130 | }); 131 | 132 | return null; 133 | } 134 | } 135 | 136 | this.state = new State({ 137 | one: { 138 | test: 'hey', 139 | test2: 'what\'s' 140 | }, 141 | two: { 142 | test: 'dude', 143 | test2: 'up' 144 | }, 145 | three: { 146 | test3: 'hooray!' 147 | }, 148 | four: { 149 | test4: 'works!' 150 | }, 151 | five: { 152 | test4: 'huyak' 153 | }, 154 | six: { 155 | test5: 'updated' 156 | }, 157 | eight: { 158 | nestednested: 'wat' 159 | }, 160 | foo: { 161 | magic: { 162 | bar: 'test3' 163 | }, 164 | huyagic: { 165 | bar: 'test4' 166 | } 167 | } 168 | }, { 169 | asynchronous: false 170 | }); 171 | 172 | this.nestedPath = nestedPath; 173 | this.nestedNestedPath = nestedNestedPath; 174 | 175 | this.parentDataFactory = chai.spy(() => ({ 176 | parentProp: [ 'parentStuff' ] 177 | })); 178 | this.dataFactory = chai.spy(props => { 179 | return { 180 | one: [ 'one', nestedPath ], 181 | two: [ 'two', nestedPath ], 182 | complex: [ 'three', [ 'foo', props.stuff, 'bar' ] ], 183 | complexParent: [ 'four', [ 'foo', props.parentProp, 'bar' ] ], 184 | complexParent2: [ 'five', [ 'foo', props.parentProp, 'bar' ] ], 185 | complexParent3: [ 'six', [ 'foo', props.parentProp, 'bar' ] ], 186 | weirdo: [ 'seven', props.parentProp ], 187 | complexNestedParent: [ 'eight', this.nestedNestedPath ] 188 | }; 189 | }); 190 | 191 | this.renderMock = function(props) { 192 | getRenderedDOM( 193 | DataInit(this.state)( 194 | DataWatcher(this.dataFactory)(Component) 195 | ), 196 | props 197 | ); 198 | }; 199 | 200 | this.renderParentMock = function(props) { 201 | getRenderedDOM( 202 | DataInit(this.state)( 203 | DataWatcher(this.parentDataFactory)( 204 | DataWatcher(this.dataFactory)(Component) 205 | ) 206 | ), 207 | props 208 | ); 209 | }; 210 | }); 211 | 212 | it('simple', function() { 213 | this.renderMock(); 214 | 215 | this.state.setIn(this.nestedPath, 'test'); 216 | 217 | expect(this.propsSpy).to.be.called.with('hey'); 218 | expect(this.propsSpy).to.be.called.with('dude'); 219 | 220 | this.state.setIn(this.nestedPath, 'test2'); 221 | expect(this.propsSpy).to.be.called.with('what\'s'); 222 | expect(this.propsSpy).to.be.called.with('up'); 223 | }); 224 | 225 | it('nested-in-nested', function() { 226 | this.renderMock(); 227 | this.state.setIn([ 'nested' ], 'test'); 228 | this.state.setIn([ 'foo', 'bar', 'test' ], 'nestednested'); 229 | 230 | expect(this.propsSpy).to.be.called.with('wat'); 231 | }); 232 | 233 | it('props-based', function() { 234 | this.renderMock({ stuff: 'magic' }); 235 | 236 | expect(this.propsSpy).to.be.called.with('hooray!'); 237 | }); 238 | 239 | it('props-based (with initial data) when props changed', function() { 240 | this.renderParentMock(); 241 | this.state.setIn([ 'parentStuff' ], 'incorrect'); 242 | this.state.setIn([ 'parentStuff' ], 'huyagic'); 243 | 244 | expect(this.propsSpy).to.be.called.with('works!'); 245 | expect(this.propsSpy).to.be.called.with('huyak'); 246 | }); 247 | 248 | it('props-based (with no initial data) when props changed', function() { 249 | this.renderParentMock(); 250 | this.state.setIn([ 'parentStuff' ], 'amazing'); 251 | this.state.setIn([ 'foo', 'amazing', 'bar' ], 'test5'); 252 | 253 | expect(this.propsSpy).to.be.called.with('updated'); 254 | }); 255 | 256 | it('should not cause memory leak with empty nested path', function() { 257 | this.renderMock({ stuff: [] }); 258 | this.state.setIn(this.nestedPath, 'test'); 259 | this.state.setIn(this.nestedPath, 'test1'); 260 | this.state.setIn(this.nestedPath, 'test2'); 261 | 262 | expect(this.dataFactory).to.be.called.exactly(4); 263 | }); 264 | 265 | it('should not cause memory leak with more than one nested', function() { 266 | this.renderMock(); 267 | this.state.setIn(this.nestedPath, 'test'); 268 | this.state.setIn(this.nestedPath, 'test1'); 269 | this.state.setIn(this.nestedPath, 'test2'); 270 | 271 | expect(this.dataFactory).to.be.called.exactly(4); 272 | }); 273 | }); 274 | }); 275 | 276 | describe('when unmounted', function() { 277 | beforeEach(function() { 278 | class Component extends React.Component { 279 | render() { 280 | return ( 281 |
282 | ); 283 | } 284 | } 285 | 286 | this.dataFactory = chai.spy(() => { 287 | return { 288 | one: [ 'one', 'test' ], 289 | two: [ 'two', 'test' ] 290 | }; 291 | }); 292 | 293 | this.state = new State({}, { 294 | asynchronous: false 295 | }); 296 | 297 | this.renderMock = function(props) { 298 | return getRenderedDOM( 299 | DataInit(this.state)( 300 | DataWatcher(this.dataFactory)(Component) 301 | ), 302 | props 303 | ); 304 | }; 305 | 306 | const mountedComponent = this.renderMock(); 307 | 308 | ReactDOM.unmountComponentAtNode(mountedComponent.parentNode); 309 | }); 310 | 311 | it('should unwatch cursors', function() { 312 | const tree = this.state.getTree(); 313 | 314 | expect(tree.select([ 'one', 'test' ]).listeners('update')).to.have.length(0); 315 | expect(tree.select([ 'two', 'test' ]).listeners('update')).to.have.length(0); 316 | }); 317 | 318 | it('should clear the queue', function() { 319 | expect(this.state._watchingQueue).to.have.length(0); 320 | }); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /test/lib/index.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { 3 | State, 4 | DataWatcher, 5 | DataFetcher, 6 | DataSender, 7 | DataInit 8 | } from 'lib/index'; 9 | 10 | describe('index', () => { 11 | it('state exists', () => { 12 | expect(State).to.exist; 13 | }); 14 | it('DataWatcher exists', () => { 15 | expect(DataWatcher).to.exist; 16 | }); 17 | it('DataFetcher exists', () => { 18 | expect(DataFetcher).to.exist; 19 | }); 20 | it('DataSender exists', () => { 21 | expect(DataSender).to.exist; 22 | }); 23 | it('DataInit exists', () => { 24 | expect(DataInit).to.exist; 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/lib/state.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import spies from 'chai-spies'; 3 | 4 | import State from 'lib/state'; 5 | 6 | chai.use(spies); 7 | 8 | describe('state', () => { 9 | it('exists', () => { 10 | expect(State).to.exist; 11 | }); 12 | 13 | describe('default', () => { 14 | const state = new State(); 15 | 16 | it('is instance of State', () => { 17 | expect(state instanceof State).to.be.true; 18 | }); 19 | 20 | it('is empty data', () => { 21 | expect(Object.keys(state.get()).length).to.be.equal(0); 22 | }); 23 | }); 24 | 25 | describe('initial data and options', () => { 26 | const state = new State({ 27 | test: 'hello' 28 | }, { 29 | immutable: false 30 | }); 31 | 32 | it('has initial data', () => { 33 | expect(state.getIn('test')).to.be.equal('hello'); 34 | }); 35 | 36 | it('has options', () => { 37 | expect(() => { 38 | state.get().test = 'bye'; 39 | }).to.not.throw(); 40 | }); 41 | }); 42 | 43 | describe('public API', () => { 44 | const initialState = { 45 | test: 'hello' 46 | }; 47 | let state = null; 48 | 49 | beforeEach(function() { 50 | state = new State(initialState, { 51 | asynchronous: false 52 | }); 53 | }); 54 | 55 | it('getTree()', () => { 56 | expect(state.getTree()).to.be.equal(state.getTree().root.tree); 57 | }); 58 | 59 | it('get()', () => { 60 | expect(state.get()).to.be.equal(initialState); 61 | }); 62 | 63 | it('getIn()', () => { 64 | expect(state.getIn('test')).to.be.equal('hello'); 65 | }); 66 | 67 | it('set()', () => { 68 | state.set({ 69 | what: 'the fuck' 70 | }); 71 | expect(state.getIn('what')).to.be.equal('the fuck'); 72 | }); 73 | 74 | it('setIn()', () => { 75 | state.setIn('test', 'bye'); 76 | expect(state.getIn('test')).to.be.equal('bye'); 77 | }); 78 | 79 | it('unset()', () => { 80 | state.unset(); 81 | expect(state.get()).to.be.undefined; 82 | }); 83 | 84 | it('unsetIn()', () => { 85 | state.unsetIn('test'); 86 | expect(state.getIn('test')).to.be.undefined; 87 | }); 88 | 89 | it('exists()', () => { 90 | expect(state.exists('test')).to.be.true; 91 | }); 92 | }); 93 | 94 | describe('DataFetcher helpers', () => { 95 | const path = [ 'test', 'huyest' ]; 96 | let state = null; 97 | let callback = null; 98 | let matchersFactories = null; 99 | 100 | beforeEach(function() { 101 | state = new State({ 102 | test: { 103 | } 104 | }, { 105 | asynchronous: false 106 | }); 107 | callback = chai.spy(function() {}); 108 | matchersFactories = [ 109 | () => [ 110 | { path, callback } 111 | ] 112 | ]; 113 | }); 114 | 115 | describe('_registerFetcher()', () => { 116 | it('should register fetcher', () => { 117 | state._registerFetcher(matchersFactories); 118 | expect(state._fetchers.length).to.be.equal(1); 119 | }); 120 | }); 121 | 122 | describe('_processFetcherWithPath()', () => { 123 | it('should add only one listener for each fetcher', () => { 124 | state._registerFetcher(matchersFactories); 125 | state._registerFetcher(matchersFactories); 126 | state.getIn(path); 127 | expect(callback).to.have.been.called.twice; 128 | }); 129 | }); 130 | 131 | describe('_unregisterFetcher()', () => { 132 | it('should unregister', () => { 133 | state._registerFetcher(matchersFactories); 134 | state._unregisterFetcher(matchersFactories); 135 | state.getIn(path); 136 | expect(callback).to.not.have.been.called.once; 137 | expect(state._fetchers.length).to.be.equal(0); 138 | }); 139 | }); 140 | }); 141 | 142 | describe('DataSender helpers', () => { 143 | const path = [ 'test', 'huyest' ]; 144 | let state = null; 145 | let callback = null; 146 | let matchersFactories = null; 147 | 148 | beforeEach(function() { 149 | state = new State({}, { 150 | asynchronous: false 151 | }); 152 | callback = chai.spy(function() {}); 153 | matchersFactories = [ 154 | () => [ 155 | { path, callback } 156 | ] 157 | ]; 158 | }); 159 | 160 | describe('_registerSender()', () => { 161 | it('should register sender', () => { 162 | state._registerSender(matchersFactories); 163 | expect(state._senders.length).to.be.equal(1); 164 | }); 165 | }); 166 | 167 | describe('_processSenderWithPath()', () => { 168 | it('should add only one listener for each sender', () => { 169 | state._registerSender(matchersFactories); 170 | state._registerSender(matchersFactories); 171 | state.setIn(path, 'hello'); 172 | expect(callback).to.have.been.called.twice; 173 | }); 174 | 175 | it('should have a value in the callback', () => { 176 | state._registerSender(matchersFactories); 177 | state.setIn(path, 'hello'); 178 | expect(callback).to.have.been.called.with('hello'); 179 | }); 180 | 181 | it('should have an undefined value in the callback', () => { 182 | state._registerSender(matchersFactories); 183 | state.setIn(path, 'hello'); 184 | state.unsetIn(path); 185 | expect(callback).to.have.been.called.with(undefined); 186 | }); 187 | }); 188 | 189 | describe('_unregisterSender()', () => { 190 | it('should unregister', () => { 191 | state._registerSender(matchersFactories); 192 | state._unregisterSender(matchersFactories); 193 | state.setIn(path, 'hello'); 194 | expect(callback).to.not.have.been.called.once; 195 | expect(state._senders.length).to.be.equal(0); 196 | }); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /test/lib/throw-error.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import throwError from 'lib/throw-error'; 3 | 4 | describe('throw-error', () => { 5 | it('exists', () => { 6 | expect(throwError).to.exist; 7 | }); 8 | 9 | it('should throw error for component', () => { 10 | try { 11 | throwError('wat', 'Test'); 12 | } catch (e) { 13 | expect(e.message).to.equal('doob, Test component: wat'); 14 | } 15 | }); 16 | 17 | it('should throw error for unknown component', () => { 18 | try { 19 | throwError('wat'); 20 | } catch (e) { 21 | expect(e.message).to.equal('doob, unknown component: wat'); 22 | } 23 | }); 24 | }); 25 | --------------------------------------------------------------------------------