├── .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 | [](http://unmaintained.tech)
12 | [](https://www.npmjs.com/package/doob)
13 | [](https://www.npmjs.com/package/doob)
14 | [](https://travis-ci.org/mistadikay/doob)
15 | [](https://coveralls.io/r/mistadikay/doob)
16 | [](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 |
--------------------------------------------------------------------------------