├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── doghousediagram.png ├── examples └── counters │ ├── public │ └── index.html │ ├── server.js │ ├── src │ ├── App.css │ ├── App.js │ ├── Counter.js │ ├── index.css │ └── index.js │ └── webpack.config.babel.js ├── package.json ├── src ├── ScopedActionFactory.js ├── bindActionCreatorsDeep.js ├── bindScopedActionFactories.js ├── index.js ├── scopeActionCreators.js ├── scopeReducers.js └── utils │ ├── memoize.js │ └── object-shim.js ├── test ├── .eslintrc ├── ScopedActionFactory.spec.js ├── bindActionCreatorsDeep.spec.js ├── bindScopedActionFactories.spec.js ├── helpers │ ├── actions.js │ └── reducers.js ├── scopeActionCreators.spec.js └── scopeReducers.spec.js └── webpack.config.babel.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es": { 4 | "presets": ["stage-3"] 5 | }, 6 | "umd": { 7 | "presets": ["es2015", "stage-3"] 8 | }, 9 | "commonjs": { 10 | "presets": ["es2015", "stage-3"] 11 | }, 12 | "examples": { 13 | "presets": ["es2015", "stage-3", "react"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "plugins": [ 9 | "react" 10 | ], 11 | 12 | "extends": "eslint:recommended", 13 | "rules": { 14 | "camelcase": [1, { "properties": "never" }], 15 | "new-cap": 0, 16 | "no-console": 0, 17 | "no-eval": 2, 18 | "no-multi-spaces": 1, 19 | "no-bitwise": 2, 20 | "no-redeclare": 1, 21 | "no-plusplus": 0, 22 | "no-new": 2, 23 | "no-undef": 2, 24 | "no-underscore-dangle": 0, 25 | "no-unused-vars": 1, 26 | "no-use-before-define": 0, 27 | "one-var": [1, "never"], 28 | "strict": 0, 29 | "no-unsafe-negation": "error", 30 | "no-octal": "error", 31 | "no-octal-escape": "error", 32 | "array-bracket-spacing": [ 33 | "error", 34 | "never" 35 | ], 36 | "block-spacing": [ 37 | "error", 38 | "always" 39 | ], 40 | "comma-spacing": [ 41 | "error", 42 | { 43 | "before": false, 44 | "after": true 45 | } 46 | ], 47 | "computed-property-spacing": [ 48 | "error", 49 | "never" 50 | ], 51 | "eol-last": "error", 52 | "indent": [ 53 | 2, 54 | 4, 55 | { 56 | "SwitchCase": 1 57 | } 58 | ], 59 | "key-spacing": [ 60 | "error", 61 | { 62 | "beforeColon": false, 63 | "afterColon": true 64 | } 65 | ], 66 | "keyword-spacing": [ 67 | "error", 68 | { 69 | "before": true, 70 | "after": true, 71 | "overrides": { 72 | "return": { 73 | "after": true 74 | }, 75 | "throw": { 76 | "after": true 77 | }, 78 | "case": { 79 | "after": true 80 | } 81 | } 82 | } 83 | ], 84 | "linebreak-style": [ 85 | "error", 86 | "unix" 87 | ], 88 | "no-multiple-empty-lines": [ 89 | "error", 90 | { 91 | "max": 2, 92 | "maxEOF": 1 93 | } 94 | ], 95 | "no-trailing-spaces": "error", 96 | "no-whitespace-before-property": "error", 97 | "object-curly-spacing": [ 98 | "error", 99 | "always" 100 | ], 101 | "padded-blocks": [ 102 | "error", 103 | "never" 104 | ], 105 | "quotes": [ 106 | "error", 107 | "single", 108 | { 109 | "avoidEscape": true 110 | } 111 | ], 112 | "semi-spacing": [ 113 | "error", 114 | { 115 | "before": false, 116 | "after": true 117 | } 118 | ], 119 | "semi": [ 120 | "error", 121 | "always" 122 | ], 123 | "space-before-blocks": "error", 124 | "space-before-function-paren": [ 125 | "error", 126 | { 127 | "anonymous": "always", 128 | "named": "never" 129 | } 130 | ], 131 | "space-in-parens": [ 132 | "error", 133 | "never" 134 | ], 135 | "space-infix-ops": "error", 136 | "space-unary-ops": [ 137 | "error", 138 | { 139 | "words": true, 140 | "nonwords": false, 141 | "overrides": {} 142 | } 143 | ], 144 | "spaced-comment": [ 145 | "error", 146 | "always", 147 | { 148 | "exceptions": [ 149 | "-", 150 | "+" 151 | ], 152 | "markers": [ 153 | "=", 154 | "!" 155 | ] 156 | } 157 | ], 158 | "unicode-bom": [ 159 | "error", 160 | "never" 161 | ], 162 | "arrow-spacing": [ 163 | "error", 164 | { 165 | "before": true, 166 | "after": true 167 | } 168 | ], 169 | "no-useless-rename": [ 170 | "error", 171 | { 172 | "ignoreDestructuring": false, 173 | "ignoreImport": false, 174 | "ignoreExport": false 175 | } 176 | ], 177 | "no-var": "error", 178 | "object-shorthand": [1, "always"], 179 | "rest-spread-spacing": [ 180 | "error", 181 | "never" 182 | ], 183 | "template-curly-spacing": "error", 184 | "yield-star-spacing": [ 185 | "error", 186 | "after" 187 | ], 188 | "react/jsx-no-duplicate-props": 2, 189 | "react/jsx-uses-react": 2, 190 | "react/jsx-uses-vars": 1, 191 | "react/react-in-jsx-scope": 2, 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.DS_Store 3 | node_modules 4 | dist 5 | lib 6 | es 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Datadog, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-doghouse 2 | 3 | `redux-doghouse` is a library that aims to make **reusable components** easier to build with Redux by **scoping actions and reducers** to a particular instance of a component. 4 | 5 | ![A diagram of how Redux-Doghouse works](doghousediagram.png) 6 | 7 | It includes tools to help you build **Scoped Actions** and **Scoped Reducers** with minimal modifications to your code. That way, if you build a Redux store for a `Parent` with an **arbitrary number** of `Children` (meaning there can be none, one, or a million of them), actions affecting `Child A` won't affect `Child B` through `Child Z`. 8 | 9 | **You can read more about why we built `redux-doghouse`, and our real-world use-case, in this [blog post](http://engineering.datadoghq.com/redux-doghouse--creating-reusable-react-redux-components-through-scoping/).** 10 | 11 | # Getting Started 12 | ## Installation 13 | ``` 14 | npm install redux-doghouse 15 | ``` 16 | 17 | ## Running Examples 18 | ### Counters ([Live Demo](https://redux-doghouse-example.now.sh/)) 19 | An app that renders an arbitrary number of ``s, with the ability to change the value of one of them at a time, all of them at once, or only the ones with even or odd numbers 20 | ``` 21 | npm run counters 22 | ``` 23 | 24 | # API 25 | 1. [scopeActionCreators](#scopeactioncreatorsactioncreators-scopeID) 26 | 2. [ScopedActionFactory](#scopedactionfactoryactioncreators) 27 | 3. [bindScopedActionFactories](#bindscopedactionfactoriesactionfactories-dispatch-bindfn) 28 | 4. [bindActionCreatorsDeep](#bindactioncreatorsdeepactioncreatortree-dispatch) 29 | 5. [scopeReducers](#scopereducersreducers) 30 | 31 | ## For Actions 32 | ### `scopeActionCreators(actionCreators, scopeID)` 33 | Adds a scope to the output of a set of action creators 34 | 35 | The `actionCreators` should be in the same format that you would pass to `bindActionCreators`. e.g: 36 | ```javascript 37 | (value) => ({type: SET_FOO, value}) 38 | ``` 39 | or 40 | ```javascript 41 | { 42 | foo: (value) => ({type: SET_FOO, value}) 43 | } 44 | ``` 45 | It will then **scope** each of these action creators, so that the resulting action will include the `scopeID`. 46 | ```javascript 47 | { 48 | foo: (value) => ({ 49 | type: SET_FOO, value, 50 | scopeID: "[the specified scopeID]" 51 | }) 52 | } 53 | ``` 54 | 55 | #### Arguments 56 | - `actionCreators` *(Function or Object)*: An action creator, or an object whose values are action creators. The values can also be nested objects whose values are action creators (or more nested objects, and so on). 57 | - `scopeID` *(String or Number)*: An identifier to include in any actions created by the `actionCreators` 58 | 59 | #### Returns 60 | 61 | *(Object or Function)*: An object mimicking the original object, but with each function adding `{ scopeID }` to the *Object* that they return. If you passed a function as `actionCreators`, the return value will also be a single function. 62 | 63 | #### Example 64 | ```javascript 65 | import { scopeActionCreators } from 'redux-doghouse'; 66 | import { actionCreators } from './my-actions'; 67 | 68 | // Before scoping: 69 | actionCreators.foo('bar'); 70 | // Will return 71 | { 72 | type: 'SET_FOO', 73 | value: 'bar' 74 | }; 75 | /// After scoping: 76 | scopeActionCreators(actionCreators, 'a').foo('bar') 77 | // Will return 78 | { 79 | type: 'SET_FOO', 80 | value: 'bar', 81 | scopeID: 'a' 82 | } 83 | ``` 84 | 85 | ### `ScopedActionFactory(actionCreators)` 86 | Works similarly to `scopeActionCreators`, but with the added benefit of `instanceof` checking. This allows you to write a check to see whether or not a set of action creators is an `instanceof ScopedActionFactory`. 87 | 88 | For example, the included `bindActionCreatorsDeep` function will intelligently bind an object tree of both scoped and un-scoped action creators, depending on whether it's passed plain objects or `ScopedActionFactory` instances. 89 | 90 | #### Arguments 91 | - `actionCreators` *(Function or Object)*: An action creator, or an object whose values are action creators 92 | 93 | #### Returns 94 | 95 | *(ScopedActionFactory)* A class of object with the following: 96 | 97 | ##### Instance Methods 98 | ###### `scope(id): Object` 99 | Runs `scopeActionCreators(id)` on the `actionCreators` that were passed to the `new ScopeActionFactory`, and returns the result. 100 | 101 | #### Example 102 | ```javascript 103 | import { ScopedActionFactory } from 'redux-doghouse'; 104 | import { actionCreators } from './my-actions'; 105 | 106 | const scopeableActions = new ScopedActionFactory(actionCreators); 107 | const actionCreatorsScopedToA = scopeableActions.scope('a'); 108 | const actionCreatorsScopedToB = scopeableActions.scope('b'); 109 | 110 | actionCreatorsScopedToA.foo('bar') 111 | // Will return 112 | { 113 | type: SET_FOO, 114 | value: 'bar', 115 | scopeID: 'a' 116 | } 117 | ``` 118 | 119 | ### `bindScopedActionFactories(actionFactories, dispatch, [bindFn])` 120 | Takes an object of `actionFactories` and binds them all to a `dispatch` function. By default, it will use Redux's included `bindActionCreators` to do this, but you can specify a `bindFn` to use instead. 121 | 122 | #### Arguments 123 | - `actionFactories` *(Object or ScopedActionFactory)*: A single ScopedActionFactory, or an object whose values are ScopedActionFactories. 124 | - `dispatch` *(Function)*: A function to which the resulting action creators from `actionFactories` should be dispatched; usually this is the `dispatch` method of a Redux store 125 | - [`bindFn`] *(Function)*: If specified, this function will be used to bind resulting action creators to the `dispatch`. If unspecified, Redux's native `bindActionCreators` will be used by default. 126 | 127 | #### Returns 128 | *(Object or ScopedActionFactory)*: An object mimicking the original object, but with each `ScopedActionFactory` generating functions that will immediately dispatch the action returned by the corresponding action creator. If you passed a single factory as `actionFactories`, the return value will also be a single factory. 129 | 130 | #### Example 131 | ```javascript 132 | import { createStore } from 'redux'; 133 | import { ScopedActionFactory, bindScopedActionFactories } from 'redux-doghouse'; 134 | import { actionCreators, reducers } from './my-redux-component'; 135 | 136 | const store = createStore(reducers); 137 | const scopeableActions = { 138 | myComponentActions: new ScopedActionFactory(actionCreators); 139 | } 140 | const boundScopeableActions = bindScopedActionFactories(scopeableActions, store.dispatch); 141 | ``` 142 | 143 | ### `bindActionCreatorsDeep(actionCreatorTree, dispatch)` 144 | Extends Redux's native `bindActionCreators` to allow you to bind a whole tree of nested action creator `function`s and `ScopedActionFactory` instances to a `dispatch` function. 145 | 146 | #### Arguments 147 | - `actionCreatorTree` *(Object)*: An object whose values can be: 148 | - A *Function* to be bound to the `dispatch` 149 | - A `ScopedActionFactory` to be bound to the `dispatch` 150 | - Another *Object* containing either of these (or more nested *Object*) 151 | - `dispatch` *(Function)*: A function to which the members of `actionCreatorTree` should be dispatched; usually this is the `dispatch` method of a Redux store 152 | 153 | #### Example 154 | ```javascript 155 | import { bindActionCreatorsDeep, ScopedActionFactory } from 'redux-doghouse'; 156 | import { createStore } from 'redux'; 157 | import { reducers } from './my-reducers'; 158 | 159 | const store = createStore(reducers); 160 | 161 | const actionCreators = { 162 | fooActions: { 163 | bar: (value) => ({type: 'BAR', value}) 164 | }, 165 | barActions: { 166 | baz: new ScopedActionFactory({ 167 | quux: (value) => ({type: 'QUUX'}) 168 | }) 169 | } 170 | } 171 | const boundActionCreators = bindActionCreatorsDeep(boundActionCreators, store.dispatch); 172 | ``` 173 | 174 | ## For Reducers 175 | 176 | ### `scopeReducers(reducers)` 177 | This acts as an extension of Redux's `combineReducers`, which takes an object of `reducers` in the form of `{ [prop]: reducer(state, action) }` pairs and combines them into a single reducer that returns `{ [prop]: state }` pairs. `scopeReducers` goes a step further and returns an object of `{ [scopeID]: { [prop]: state} }` pairs. 178 | 179 | In other words, it will create a reference to your `reducers` for each new `scopeID` you add to your data model, and route scoped actions to their corresponding `scopeID` when reducing a new state. 180 | 181 | #### Arguments 182 | - `reducers` *(Object)*: An object whose keys correspond to property names, and whose values correspond to different reducing functions that need to be combined into one and reused across multiple scopes. 183 | 184 | #### Returns 185 | *(Function)*: A reducer that takes an object of state objects in the form of `{ [scopeID]: state }` pairs, and an action that includes a `scopeID`. The reducer will return a new object mimicking the original object, but for each key: 186 | 187 | 1. For the matching `scopeID`, invoke the `reducers` to construct a new state object with the same shape as the `reducers` 188 | 2. Leave all the other `scopeID`s' state objects unchanged 189 | 190 | #### Example 191 | ```javascript 192 | // Given these actionCreators... 193 | import { scopeActionCreators } from 'redux-doghouse'; 194 | const reducers = { 195 | foo: (state = 0, action) => { 196 | switch (action.type) { 197 | case 'INCREMENT_FOO': 198 | return state + 1; 199 | case 'DECREMENT_FOO': 200 | return state - 1; 201 | default: 202 | return state; 203 | } 204 | } 205 | }; 206 | const actionCreatorsA = scopeActionCreators({ 207 | incrementFoo: () => ({type: 'INCREMENT_FOO'}) 208 | }, 'a'); 209 | 210 | // Without scoping 211 | import { combineReducers } from 'redux'; 212 | const combinedReducers = combineReducers(reducers); 213 | const state = {foo: 0}; 214 | combinedReducers(state, actionCreatorsA.incrementFoo()); 215 | // Will return 216 | {foo: 1} 217 | 218 | // With scoping 219 | import { scopeReducers } from 'redux-doghouse'; 220 | const scopedReducers = scopeReducers(reducers); 221 | const state = { 222 | a: {foo: 0}, 223 | b: {foo: 2} 224 | }; 225 | scopedReducers(state, actionCreatorsA.incrementFoo()); 226 | // Will return 227 | { 228 | a: {foo: 1}, 229 | b: {foo: 2} 230 | } 231 | ``` 232 | -------------------------------------------------------------------------------- /doghousediagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataDog/redux-doghouse/a9082292ea420cc89f0121876832cb713aad8438/doghousediagram.png -------------------------------------------------------------------------------- /examples/counters/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redux-Doghouse - Counters Example 7 | 8 | 9 |
10 |

Redux-Doghouse - Counters

11 |

12 | Each counter is written as if it were a tiny Redux app that can dispatch and reduce for 13 | INCREMENT and DECREMENT 14 | actions. 15 |

16 | Redux-Doghouse prevents + or - button-clicks on one 17 | counter to affect all of the others, but allows the row of buttons below to increment or 18 | decrement multiple counters at a time. 19 |

20 |
21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/counters/server.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import webpackDevMiddleware from 'webpack-dev-middleware'; 3 | import webpackHotMiddleware from 'webpack-hot-middleware'; 4 | import config from './webpack.config.babel'; 5 | import Express from 'express'; 6 | 7 | 8 | const app = new Express(); 9 | const port = 11111; 10 | 11 | const compiler = webpack(config); 12 | app.use(webpackDevMiddleware(compiler, { 13 | noInfo: true, publicPath: config.output.publicPath 14 | })); 15 | app.use(webpackHotMiddleware(compiler)); 16 | 17 | app.use(function (req, res) { 18 | res.sendFile(__dirname + '/public/index.html'); 19 | }); 20 | 21 | app.listen(port, function (error) { 22 | if (error) { 23 | console.error(error); 24 | } else { 25 | console.info( 26 | 'Server running on http://localhost:' + port 27 | ); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /examples/counters/src/App.css: -------------------------------------------------------------------------------- 1 | header { 2 | width: 90vw; 3 | text-align: center; 4 | margin: auto; 5 | } 6 | 7 | header p { 8 | font-size: 14pt; 9 | } 10 | 11 | header .code { 12 | color: #f44; 13 | background: #e0e0e0; 14 | font-family: monospace; 15 | padding: 4px; 16 | } 17 | 18 | .App { 19 | text-align: center; 20 | } 21 | 22 | .App-controls { 23 | width: 100%; 24 | display: flex; 25 | justify-content: space-around; 26 | padding: 24px; 27 | box-sizing: border-box; 28 | } 29 | 30 | .App-counters { 31 | display: flex; 32 | width: 620px; 33 | margin: auto; 34 | flex-flow: row wrap; 35 | align-items: center; 36 | justify-content: space-around; 37 | } 38 | 39 | .Counter { 40 | display: flex; 41 | width: 128px; 42 | height: 128px; 43 | flex-flow: column wrap; 44 | align-items: center; 45 | justify-content: center; 46 | flex: 0 0 auto; 47 | } 48 | 49 | .Counter-amount { 50 | display: block; 51 | font-size: 128px; 52 | line-height: 128px; 53 | } 54 | 55 | button { 56 | background: #774aa4; 57 | border: 1px solid #694191; 58 | color: white; 59 | font-size: 32px; 60 | cursor: pointer; 61 | border-radius: 6px; 62 | } 63 | 64 | button:hover { 65 | filter: brightness(130%); 66 | } 67 | button:active { 68 | filter: brightness(110%) hue-rotate(40deg); 69 | } 70 | 71 | .Counter-button { 72 | flex: 1 1 auto; 73 | margin: 4px; 74 | height: 48px; 75 | width: 52px; 76 | box-sizing: border-box; 77 | } 78 | -------------------------------------------------------------------------------- /examples/counters/src/App.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers } from 'redux'; 2 | import { Provider, connect } from 'react-redux'; 3 | import React from 'react'; 4 | import { scopeReducers, ScopedActionFactory, bindActionCreatorsDeep } 5 | from 'redux-doghouse'; 6 | import { mapValues, omit } from 'lodash'; 7 | import './App.css'; 8 | 9 | import { 10 | reducers as counterReducers, 11 | actionCreators as counterActionCreators, 12 | View as CounterView 13 | } from './Counter'; 14 | 15 | 16 | // Actions 17 | // ======= 18 | const INCREMENT_ALL = Symbol('App/INCREMENT_ALL'); 19 | const INCREMENT_EVEN = Symbol('App/INCREMENT_EVEN'); 20 | const INCREMENT_ODD = Symbol('App/INCREMENT_ODD'); 21 | const DECREMENT_ALL = Symbol('App/DECREMENT_ALL'); 22 | const DECREMENT_EVEN = Symbol('App/DECREMENT_EVEN'); 23 | const DECREMENT_ODD = Symbol('App/DECREMENT_ODD'); 24 | 25 | const ADD_COUNTER = Symbol('App/ADD_COUNTER'); 26 | const DELETE_COUNTER = Symbol('App/DELETE_COUNTER'); 27 | 28 | const actionCreators = { 29 | counterActions: new ScopedActionFactory(counterActionCreators), 30 | incrementAll: () => ({ type: INCREMENT_ALL }), 31 | decrementAll: () => ({ type: DECREMENT_ALL }), 32 | addCounter: () => ({ type: ADD_COUNTER }), 33 | deleteCounter: () => ({ type: DELETE_COUNTER }), 34 | incrementEven: () => ({ type: INCREMENT_EVEN }), 35 | decrementEven: () => ({ type: DECREMENT_EVEN }), 36 | incrementOdd: () => ({ type: INCREMENT_ODD }), 37 | decrementOdd: () => ({ type: DECREMENT_ODD }) 38 | }; 39 | 40 | // Reducers 41 | // ======== 42 | 43 | // Build counterReducers in both scoped and unscoped form 44 | // Unscoped is for acting on all of them; scoped is for routing 45 | // indiviudal Counter actions to the Counter that dispatched them 46 | const scopedCounterReducers = scopeReducers(counterReducers); 47 | const unscopedCounterReducers = combineReducers(counterReducers); 48 | const reducers = { 49 | counterStates: (state = { 0: {}, 1: {} }, action) => { 50 | const incrementAction = counterActionCreators.increment(); 51 | const decrementAction = counterActionCreators.decrement(); 52 | // Reduce for all the actions that can affect counters globally 53 | // By default, pass the action to the scopedCounterReducers, which 54 | // will handle updating a single counter in response to a scoped action 55 | switch (action.type) { 56 | case INCREMENT_ALL: 57 | return mapValues( 58 | state, s => unscopedCounterReducers(s, incrementAction) 59 | ); 60 | case DECREMENT_ALL: 61 | return mapValues( 62 | state, s => unscopedCounterReducers(s, decrementAction) 63 | ); 64 | case INCREMENT_EVEN: 65 | case DECREMENT_EVEN: 66 | case INCREMENT_ODD: 67 | case DECREMENT_ODD: { 68 | const incrementTypes = [INCREMENT_EVEN, INCREMENT_ODD]; 69 | const evenTypes = [INCREMENT_EVEN, DECREMENT_EVEN]; 70 | const doAction = 71 | incrementTypes.includes(action.type) ? incrementAction 72 | : decrementAction; 73 | const check = 74 | evenTypes.includes(action.type) ? s => s.amount % 2 === 0 75 | : s => s.amount % 2 === 1; 76 | return mapValues(state, s => { 77 | if (check(s)) { 78 | return unscopedCounterReducers(s, doAction); 79 | } else { 80 | return s; 81 | } 82 | }); 83 | } 84 | case ADD_COUNTER: { 85 | const nextID = 86 | Object.keys(state).reduce((a, b) => Math.max(a, b)) + 1; 87 | return { 88 | ...state, 89 | [nextID]: unscopedCounterReducers() 90 | }; 91 | } 92 | case DELETE_COUNTER: { 93 | const removeID = Object.keys(state).filter( 94 | (_, i, arr) => i === arr.length - 1 95 | ); 96 | return omit(state, removeID); 97 | } 98 | default: 99 | return scopedCounterReducers(state, action); 100 | } 101 | } 102 | }; 103 | 104 | const AppView = ({ actions, counterStates }) => { 105 | const { counterActions } = actions; 106 | const counters = 107 | Object.entries(counterStates).map(([id, state]) => { 108 | const props = { 109 | ...state, 110 | key: id, 111 | actions: counterActions.scope(id) 112 | }; 113 | return ; 114 | }); 115 | return ( 116 |
117 |
118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 |
130 |
131 | {counters} 132 |
133 |
134 | ); 135 | }; 136 | 137 | class App extends React.Component { 138 | constructor(props) { 139 | super(props); 140 | const combinedReducers = combineReducers(reducers); 141 | const store = createStore(combinedReducers); 142 | const connectToStore = 143 | connect( 144 | combinedReducers, 145 | dispatch => ({ 146 | actions: bindActionCreatorsDeep(actionCreators, dispatch) 147 | }) 148 | ); 149 | const ConnectedView = connectToStore(AppView); 150 | this.render = () => ( 151 | 152 | 153 | 154 | ); 155 | } 156 | } 157 | 158 | export default App; 159 | -------------------------------------------------------------------------------- /examples/counters/src/Counter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const INCREMENT = Symbol('Counter/INCREMENT'); 4 | const DECREMENT = Symbol('Counter/DECREMENT'); 5 | export const actionCreators = { 6 | increment: () => ({ type: INCREMENT }), 7 | decrement: () => ({ type: DECREMENT }) 8 | }; 9 | export const reducers = { 10 | amount: (state = 0, action = {}) => { 11 | switch (action.type) { 12 | case INCREMENT: 13 | return state < 9 ? state + 1 : 0; 14 | case DECREMENT: 15 | return state > 0 ? state - 1 : 9; 16 | default: 17 | return state; 18 | } 19 | } 20 | }; 21 | 22 | export const View = ({ amount, actions }) => ( 23 |
24 | {amount} 25 | 27 | 29 |
30 | ); 31 | -------------------------------------------------------------------------------- /examples/counters/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /examples/counters/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | import 'babel-polyfill'; 6 | 7 | ReactDOM.render( 8 | , 9 | document.getElementById('root') 10 | ); 11 | -------------------------------------------------------------------------------- /examples/counters/webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | import autoprefixer from 'autoprefixer'; 6 | 7 | const reduxDoghouseSrc = path.join(__dirname, '..', '..', 'src'); 8 | const reduxDoghouseNodeModules = path.join(__dirname, '..', '..', 'node_modules'); 9 | 10 | const isInDoghouseRepo = 11 | fs.existsSync(reduxDoghouseSrc) && fs.existsSync(reduxDoghouseNodeModules); 12 | 13 | const doghouseLoader = { 14 | test: /\.js$/, 15 | loaders: ['babel-loader'], 16 | include: reduxDoghouseSrc 17 | }; 18 | 19 | export default { 20 | module: { 21 | loaders: [{ 22 | test: /\.js$/, 23 | loaders: ['babel-loader'], 24 | exclude: /node_modules/ 25 | }, { 26 | test: /\.css$/, 27 | loaders: ['style-loader', 'css-loader', 'postcss-loader'] 28 | }].concat(isInDoghouseRepo ? doghouseLoader : []), 29 | }, 30 | 31 | resolve: !isInDoghouseRepo ? {} : { 32 | alias: { 'redux-doghouse': reduxDoghouseSrc } 33 | }, 34 | 35 | entry: [ 36 | 'webpack-hot-middleware/client?reload=true', 37 | path.join(__dirname, 'src', 'index') 38 | ], 39 | 40 | postcss: [autoprefixer], 41 | 42 | output: { 43 | path: path.join(__dirname, 'dist'), 44 | filename: 'bundle.js', 45 | publicPath: '/static/' 46 | }, 47 | 48 | plugins: [ 49 | new webpack.optimize.OccurenceOrderPlugin(), 50 | new webpack.HotModuleReplacementPlugin(), 51 | new webpack.DefinePlugin({ 52 | 'process.env.NODE_ENV': JSON.stringify('development') 53 | }) 54 | ] 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-doghouse", 3 | "version": "1.0.2", 4 | "description": "Scoping helpers for building reusable components with Redux", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "files": [ 9 | "dist", 10 | "lib", 11 | "es", 12 | "src" 13 | ], 14 | "scripts": { 15 | "clean": "rimraf lib dist es", 16 | "lint": "eslint src", 17 | "test": "cross-env BABEL_ENV=commonjs jest", 18 | "check:src": "npm run lint && npm run test", 19 | "prepublish": "npm run clean && npm run check:src && npm run build", 20 | "build": "npm run build:commonjs && npm run build:umd && npm run build:umd:min && npm run build:es", 21 | "build:es": "cross-env BABEL_ENV=es babel src --out-dir es", 22 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib", 23 | "build:umd": "cross-env BABEL_ENV=commonjs NODE_ENV=development webpack", 24 | "build:umd:min": "cross-env BABEL_ENV=commonjs NODE_ENV=production webpack", 25 | "counters": "cross-env BABEL_ENV=examples babel-node examples/counters/server.js" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/DataDog/redux-doghouse.git" 30 | }, 31 | "keywords": [ 32 | "redux", 33 | "scope", 34 | "doghouse", 35 | "react" 36 | ], 37 | "author": "Datadog (http://www.datadog.com)", 38 | "contributors": [ 39 | "Zacqary Adam Xeper (https://github.com/zacqary)" 40 | ], 41 | "license": "MIT", 42 | "peerDependencies": { 43 | "redux": "3.x" 44 | }, 45 | "devDependencies": { 46 | "autoprefixer": "6.5.0", 47 | "babel-cli": "6.16.0", 48 | "babel-eslint": "7.0.0", 49 | "babel-loader": "6.2.5", 50 | "babel-preset-es2015": "6.16.0", 51 | "babel-preset-react": "6.16.0", 52 | "babel-preset-stage-3": "6.17.0", 53 | "cross-env": "3.1.1", 54 | "css-loader": "0.25.0", 55 | "eslint": "3.7.1", 56 | "eslint-plugin-react": "6.4.1", 57 | "express": "^4.14.0", 58 | "jest": "17.0.3", 59 | "postcss-loader": "0.13.0", 60 | "react": "15.3.2", 61 | "react-dom": "15.3.2", 62 | "react-redux": "4.4.5", 63 | "redux": "3.6.0", 64 | "style-loader": "0.13.1", 65 | "webpack": "^1.13.3", 66 | "webpack-dev-middleware": "1.8.4", 67 | "webpack-hot-middleware": "2.13.0" 68 | }, 69 | "jest": { 70 | "testRegex": "(/test/.*\\.spec.js)$" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ScopedActionFactory.js: -------------------------------------------------------------------------------- 1 | import { scopeActionCreators } from './scopeActionCreators'; 2 | // ScopedActionFactory is a class to allow for instanceof checking 3 | export class ScopedActionFactory { 4 | constructor(actionCreators) { 5 | this._creators = actionCreators; 6 | } 7 | scope(id) { 8 | return scopeActionCreators(this._creators, id); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/bindActionCreatorsDeep.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from 'redux'; 2 | import { bindScopedActionFactories } from './bindScopedActionFactories'; 3 | import { ScopedActionFactory } from './ScopedActionFactory'; 4 | 5 | import * as object from './utils/object-shim'; 6 | 7 | // Recursively bind a tree of action creators 8 | const bindActionsDeep = (creators, dispatch) => { 9 | if (typeof creators === 'function') { 10 | return bindActionCreators(creators, dispatch); 11 | } else if (!creators || typeof creators !== 'object') { 12 | throw new Error( 13 | 'bindActionCreatorsDeep expected an object or a function instead ' + 14 | `of ${creators}` 15 | ); 16 | } 17 | // Transform each creator into an action creator bound to the dispatch 18 | return object.entries(creators).reduce((result, [key, creator]) => { 19 | const creatorIsFunction = typeof creator === 'function'; 20 | const creatorIsObject = typeof creator === 'object' && creator; 21 | if (creator instanceof ScopedActionFactory || creatorIsFunction) { 22 | result[key] = 23 | bindScopedActionFactories(creator, dispatch, bindActionsDeep); 24 | } else if (creatorIsObject) { 25 | // If an action creator is another object, recursively bind 26 | // whatever's inside it 27 | result[key] = bindActionsDeep(creator, dispatch); 28 | } 29 | return result; 30 | }, {}); 31 | }; 32 | export const bindActionCreatorsDeep = (actionCreators, dispatch) => 33 | bindActionsDeep(actionCreators, dispatch); 34 | -------------------------------------------------------------------------------- /src/bindScopedActionFactories.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from 'redux'; 2 | import { ScopedActionFactory } from './ScopedActionFactory'; 3 | import * as object from './utils/object-shim'; 4 | 5 | // Actions 6 | // ======= 7 | const bindScopedActionFactory = (creator, dispatch, bindFn) => { 8 | if (creator instanceof ScopedActionFactory) { 9 | // ScopedActionFactories shouldn't bind yet; they should return a 10 | // clone which will bind them once their scope() method is called 11 | const boundFactory = new ScopedActionFactory(); 12 | boundFactory.scope = id => bindFn(creator.scope(id), dispatch); 13 | return boundFactory; 14 | } else if (typeof creator === 'function') { 15 | return bindFn(creator, dispatch); 16 | } 17 | }; 18 | 19 | export const bindScopedActionFactories = ( 20 | creators, dispatch, bindFn = bindActionCreators 21 | ) => { 22 | const isCreator = c => 23 | c instanceof ScopedActionFactory || typeof c === 'function'; 24 | if (isCreator(creators)) { 25 | return bindScopedActionFactory(creators, dispatch, bindFn); 26 | } else if (!creators || typeof creators !== 'object') { 27 | throw new Error( 28 | 'bindScopedActionFactories expected an object or a function ' + 29 | `instead of ${creators}` 30 | ); 31 | } 32 | return object.entries(creators).reduce((result, [key, creator]) => { 33 | if (isCreator(creator)) { 34 | result[key] = bindScopedActionFactory(creator, dispatch, bindFn); 35 | } else if (typeof creator === 'object') { 36 | result[key] = bindScopedActionFactories(creator, dispatch, bindFn); 37 | } 38 | return result; 39 | }, {}); 40 | }; 41 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Actions 2 | // ======= 3 | export { scopeActionCreators } from './scopeActionCreators'; 4 | export { bindScopedActionFactories } from './bindScopedActionFactories'; 5 | export { bindActionCreatorsDeep } from './bindActionCreatorsDeep'; 6 | export { ScopedActionFactory } from './ScopedActionFactory'; 7 | 8 | // Reducers 9 | // ======== 10 | export { scopeReducers } from './scopeReducers'; 11 | -------------------------------------------------------------------------------- /src/scopeActionCreators.js: -------------------------------------------------------------------------------- 1 | import { memoize } from './utils/memoize'; 2 | 3 | import * as object from './utils/object-shim'; 4 | 5 | const scopeAction = (actionCreator, id) => (...args) => ({ 6 | // Pass the args into the action creator, and spread 7 | // all the props it returns 8 | ...actionCreator(...args), 9 | // Add the scope to a prop called scopeID 10 | scopeID: id 11 | }); 12 | const scopeActionDeep = (actionCreator, id) => { 13 | switch (typeof actionCreator) { 14 | case 'function': 15 | return scopeAction(actionCreator, id); 16 | case 'object': 17 | if (!actionCreator) { 18 | throw new Error('Cannot scope null'); 19 | } 20 | return object.entries(actionCreator).reduce( 21 | (result, [key, c]) => ({ 22 | ...result, 23 | [key]: scopeActionDeep(c, id) 24 | }), {}); 25 | default: 26 | throw new Error('Can only scope a function or object'); 27 | } 28 | }; 29 | export const scopeActionCreators = memoize( 30 | // Wrap every actionCreator in a higher-order function that assigns an 31 | // additional scopeID param, allowing multiple instances of the same 32 | // component to distinguish their actions from one another 33 | (creators, id) => { 34 | if (typeof id !== 'string' && typeof id !== 'number') { 35 | const idStr = typeof id === 'symbol' ? id.toString() : id; 36 | throw new Error( 37 | `scopeActionCreators cannot scope for an id of ${idStr} - ` + 38 | 'expecting a String or Number' 39 | ); 40 | } 41 | if (typeof creators === 'function') { 42 | return scopeAction(creators, id); 43 | } 44 | return object.entries(creators).reduce( 45 | (result, [key, actionCreator]) => { 46 | try { 47 | return { 48 | ...result, 49 | [key]: scopeActionDeep(actionCreator, id) 50 | }; 51 | } catch (e) { 52 | return result; 53 | } 54 | } 55 | , { __scope__: id }); 56 | } 57 | ); 58 | -------------------------------------------------------------------------------- /src/scopeReducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { memoize } from './utils/memoize'; 3 | 4 | import * as object from './utils/object-shim'; 5 | 6 | // Reducers 7 | // ======== 8 | 9 | // Return a set of reducers that will only update if they receive an action 10 | // with a matching scopeID, or a redux initialization action 11 | const _scopeReducers = memoize((reducers, id) => { 12 | const reduce = combineReducers(reducers); 13 | return (state, action) => { 14 | const matchesID = action.scopeID && String(action.scopeID) === id; 15 | const isInitAction = action.type && typeof action.type === 'string' && 16 | action.type.slice(0, 8) === '@@redux/'; 17 | if (matchesID || isInitAction) { 18 | return reduce(state, action); 19 | } 20 | return state; 21 | }; 22 | }); 23 | 24 | // Take an object of scoped states, and return a function that will iterate 25 | // through each state and apply a scoped reducer to it 26 | export const scopeReducers = memoize( 27 | (reducers) => (states, action) => ( 28 | object.entries(states).reduce((result, [scope, state]) => ({ 29 | ...result, 30 | [scope]: _scopeReducers(reducers, scope)(state, action) 31 | }), {}) 32 | ) 33 | ); 34 | -------------------------------------------------------------------------------- /src/utils/memoize.js: -------------------------------------------------------------------------------- 1 | /** 2 | * memoize 3 | * Caches the results of a function when the arguments are the same. 4 | * Stores arguments in a tree of Maps/WeakMaps, with the cached result of the 5 | * function stored at the key of the last argument. 6 | * @example const adder = memoize((a, b, c) => a + b + c); 7 | * adder(1, 2, 3) memoizes to: 8 | * Map { 9 | * 1 => Map { 10 | * 2 => Map { 11 | * 3 => 6 12 | * } 13 | * } 14 | * } 15 | * Calling adder(1, 2, 3) again will instead traverse this Map tree instead of 16 | * computing the function again 17 | */ 18 | 19 | // memoCache - top-level WeakMap storing all the memoized results 20 | // { function => MapTree } 21 | const memoCache = new WeakMap(); 22 | export const memoize = (fn) => (...args) => { 23 | // Iterate through the function and its args to detect whether or not each 24 | // bit of data has been stored. If it hasn't been stored, compute it. 25 | // Otherwise, return the cached result. 26 | return [fn, ...args].reduce((result, arg, i, array) => { 27 | if (!result.has(arg)) { 28 | if (i === array.length - 1) { 29 | result.set(arg, fn(...args)); 30 | } else if (typeof array[i + 1] === 'object') { 31 | result.set(arg, new WeakMap()); 32 | } else { 33 | result.set(arg, new Map()); 34 | } 35 | } 36 | return result.get(arg); 37 | }, memoCache); 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/object-shim.js: -------------------------------------------------------------------------------- 1 | export const entries = 2 | Object.entries || (o => Object.keys(o).map(k => [k, o[k]])); 3 | 4 | export const values = 5 | Object.values || (o => Object.keys(o).map(k => o[k])); 6 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/ScopedActionFactory.spec.js: -------------------------------------------------------------------------------- 1 | import * as object from '../src/utils/object-shim'; 2 | import { ScopedActionFactory } from '../src'; 3 | import actionCreators from './helpers/actions'; 4 | 5 | const { testActionA, testActionB } = actionCreators; 6 | 7 | describe('ScopedActionFactory', () => { 8 | let testScopes; 9 | let TEST_VALUE; 10 | let unscopedActionA; 11 | let unscopedActionB; 12 | beforeEach(() => { 13 | testScopes = ['x', 'y', 'z', 4]; 14 | TEST_VALUE = Symbol('TEST_VALUE'); 15 | unscopedActionA = testActionA(); 16 | unscopedActionB = testActionB(TEST_VALUE); 17 | }); 18 | 19 | it('reports as an instanceof ScopedActionFactory', () => { 20 | const factory = new ScopedActionFactory(actionCreators); 21 | expect(factory instanceof ScopedActionFactory).toBeTruthy(); 22 | }); 23 | 24 | it('adds a scopeID to the result of an action creator', () => { 25 | const scopedFactoryA = new ScopedActionFactory(testActionA); 26 | const scopedFactoryB = new ScopedActionFactory(testActionB); 27 | testScopes.forEach(scope => { 28 | const scopedCreatorA = scopedFactoryA.scope(scope); 29 | const scopedCreatorB = scopedFactoryB.scope(scope); 30 | 31 | const scopedActionA = scopedCreatorA(); 32 | const scopedActionB = scopedCreatorB(TEST_VALUE); 33 | 34 | expect(scopedActionA).toEqual({ 35 | ...unscopedActionA, 36 | scopeID: scope 37 | }); 38 | expect(scopedActionB).toEqual({ 39 | ...unscopedActionB, 40 | scopeID: scope 41 | }); 42 | }); 43 | }); 44 | 45 | it('adds a scopeID to the result of an object of action creators', () => { 46 | const scopedFactory = new ScopedActionFactory(actionCreators); 47 | testScopes.forEach(scope => { 48 | const scopedCreators = scopedFactory.scope(scope); 49 | 50 | object.entries(scopedCreators).forEach(([key, creator]) => { 51 | if (key === '__scope__') { 52 | return; 53 | } 54 | const unscopedAction = actionCreators[key](TEST_VALUE); 55 | const scopedAction = creator(TEST_VALUE); 56 | expect(scopedAction).toEqual({ 57 | ...unscopedAction, 58 | scopeID: scope 59 | }); 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/bindActionCreatorsDeep.spec.js: -------------------------------------------------------------------------------- 1 | import { ScopedActionFactory, bindActionCreatorsDeep } from '../src'; 2 | import actionCreators from './helpers/actions'; 3 | import reducers from './helpers/reducers'; 4 | import { createStore } from 'redux'; 5 | 6 | describe('ScopedActionFactory', () => { 7 | let TEST_VALUE_1; 8 | let TEST_VALUE_2; 9 | let store; 10 | beforeEach(() => { 11 | store = createStore(reducers); 12 | TEST_VALUE_1 = Symbol('TEST_VALUE_1'); 13 | TEST_VALUE_2 = Symbol('TEST_VALUE_2'); 14 | }); 15 | 16 | it('binds a mix of nested and un-nested action creators and factories', () => { 17 | const factory = new ScopedActionFactory(actionCreators); 18 | const factories = { 19 | a: actionCreators.testActionA, 20 | x: factory, 21 | y: { 22 | factory, 23 | actionCreators 24 | }, 25 | z: { 26 | ...actionCreators, 27 | factory, 28 | actionCreators 29 | } 30 | }; 31 | const boundActionCreators = 32 | bindActionCreatorsDeep(factories, store.dispatch); 33 | const scopedActionsX = boundActionCreators.x.scope('scope'); 34 | const scopedActionsY = boundActionCreators.y.factory.scope('scope'); 35 | const scopedActionsZ = boundActionCreators.z.factory.scope('scope'); 36 | const actionA = boundActionCreators.a; 37 | const actionsY = boundActionCreators.y.actionCreators; 38 | const actionsZ = boundActionCreators.z; 39 | const nestedActionsZ = actionsZ.actionCreators; 40 | scopedActionsX.testActionA(); 41 | scopedActionsY.testActionA(); 42 | actionsZ.testActionA(); 43 | actionA(); 44 | scopedActionsZ.testActionA(); 45 | actionsY.testActionA(); 46 | nestedActionsZ.testActionA(); 47 | scopedActionsX.testActionB(Symbol()); 48 | scopedActionsY.testActionB(Symbol()); 49 | scopedActionsZ.testActionB(TEST_VALUE_1); 50 | expect(store.getState()).toEqual({ 51 | a: 7, 52 | b: TEST_VALUE_1 53 | }); 54 | actionsZ.testActionB(TEST_VALUE_2); 55 | expect(store.getState()).toEqual({ 56 | a: 7, 57 | b: TEST_VALUE_2 58 | }); 59 | nestedActionsZ.testActionB(TEST_VALUE_1); 60 | expect(store.getState()).toEqual({ 61 | a: 7, 62 | b: TEST_VALUE_1 63 | }); 64 | }); 65 | 66 | it('skips non-function or non-object values in the object', () => { 67 | const testCase = { 68 | ...actionCreators, 69 | foo: 42, 70 | bar: 'baz', 71 | wow: undefined, 72 | test: null 73 | }; 74 | const bound = bindActionCreatorsDeep(testCase, store.dispatch); 75 | expect( 76 | Object.keys(bound) 77 | ).toEqual( 78 | Object.keys(actionCreators) 79 | ); 80 | }); 81 | 82 | it('throws on a null or undefined actionFactories', () => { 83 | expect(() => { 84 | bindActionCreatorsDeep(null, store.dispatch); 85 | }).toThrow( 86 | 'bindActionCreatorsDeep expected an object or a function ' + 87 | 'instead of null' 88 | ); 89 | expect(() => { 90 | bindActionCreatorsDeep(undefined, store.dispatch); 91 | }).toThrow( 92 | 'bindActionCreatorsDeep expected an object or a function ' + 93 | 'instead of undefined' 94 | ); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/bindScopedActionFactories.spec.js: -------------------------------------------------------------------------------- 1 | import * as object from '../src/utils/object-shim'; 2 | import { ScopedActionFactory, bindScopedActionFactories } from '../src'; 3 | import actionCreators from './helpers/actions'; 4 | import reducers from './helpers/reducers'; 5 | import { createStore } from 'redux'; 6 | 7 | describe('ScopedActionFactory', () => { 8 | let TEST_VALUE; 9 | let store; 10 | beforeEach(() => { 11 | store = createStore(reducers); 12 | TEST_VALUE = Symbol('TEST_VALUE'); 13 | }); 14 | 15 | it('wraps the factories\' resulting action creators with the dispatch ' + 16 | 'function', () => { 17 | const factory = new ScopedActionFactory(actionCreators); 18 | const boundFactory = bindScopedActionFactories(factory, store.dispatch); 19 | const scopedActions = boundFactory.scope('a'); 20 | scopedActions.testActionA(); 21 | scopedActions.testActionA(); 22 | scopedActions.testActionA(); 23 | scopedActions.testActionB(TEST_VALUE); 24 | expect(store.getState()).toEqual({ 25 | a: 3, 26 | b: TEST_VALUE 27 | }); 28 | }); 29 | it('supports binding an object of scopedActionFactories', () => { 30 | const factory = new ScopedActionFactory(actionCreators); 31 | const factories = { 32 | x: factory, 33 | y: factory, 34 | }; 35 | const boundFactories = 36 | bindScopedActionFactories(factories, store.dispatch); 37 | const scopedActionsX = boundFactories.x.scope('scope'); 38 | const scopedActionsY = boundFactories.y.scope('scope'); 39 | scopedActionsX.testActionA(); 40 | scopedActionsY.testActionA(); 41 | scopedActionsX.testActionB(Symbol()); 42 | scopedActionsY.testActionB(TEST_VALUE); 43 | expect(store.getState()).toEqual({ 44 | a: 2, 45 | b: TEST_VALUE 46 | }); 47 | }); 48 | it('binds nested objects deeply', () => { 49 | const factory = new ScopedActionFactory(actionCreators); 50 | const factories = { 51 | x: factory, 52 | y: { actions: factory }, 53 | }; 54 | const boundFactories = 55 | bindScopedActionFactories(factories, store.dispatch); 56 | const scopedActionsX = boundFactories.x.scope('scope'); 57 | const scopedActionsY = boundFactories.y.actions.scope('scope'); 58 | scopedActionsX.testActionA(); 59 | scopedActionsY.testActionA(); 60 | scopedActionsY.testActionA(); 61 | scopedActionsX.testActionB(Symbol()); 62 | scopedActionsY.testActionB(TEST_VALUE); 63 | expect(store.getState()).toEqual({ 64 | a: 3, 65 | b: TEST_VALUE 66 | }); 67 | }); 68 | 69 | it('binds non-factory functions', () => { 70 | const factory = new ScopedActionFactory(actionCreators); 71 | const factories = { 72 | a: actionCreators.testActionA, 73 | x: factory, 74 | y: factory, 75 | z: actionCreators 76 | }; 77 | const boundFactories = 78 | bindScopedActionFactories(factories, store.dispatch); 79 | const scopedActionsX = boundFactories.x.scope('scope'); 80 | const scopedActionsY = boundFactories.y.scope('scope'); 81 | const actionsZ = boundFactories.z; 82 | const actionA = boundFactories.a; 83 | scopedActionsX.testActionA(); 84 | scopedActionsY.testActionA(); 85 | actionsZ.testActionA(); 86 | actionA(); 87 | scopedActionsX.testActionB(Symbol()); 88 | scopedActionsY.testActionB(Symbol()); 89 | actionsZ.testActionB(TEST_VALUE); 90 | expect(store.getState()).toEqual({ 91 | a: 4, 92 | b: TEST_VALUE 93 | }); 94 | }); 95 | 96 | it('can use an alternative binding function', () => { 97 | const actionBatch = new Map(); 98 | const subscribeActionTo = (creator, dispatch) => (...args) => { 99 | const result = creator(...args); 100 | if (actionBatch.has(dispatch)) { 101 | actionBatch.get(dispatch).add(result); 102 | } else { 103 | actionBatch.set(dispatch, new Set([result])); 104 | } 105 | }; 106 | const subscribeActionsTo = (creators, dispatch) => 107 | object.entries(creators).reduce((result, [key, val]) => ({ 108 | ...result, 109 | [key]: subscribeActionTo(val, dispatch) 110 | }), {}); 111 | const releaseAllActions = () => actionBatch.forEach((actions, disp) => { 112 | actions.forEach((action) => disp(action)); 113 | actions.clear(); 114 | }); 115 | const factory = new ScopedActionFactory(actionCreators); 116 | const boundFactory = 117 | bindScopedActionFactories(factory, store.dispatch, subscribeActionsTo); 118 | const scopedActions = boundFactory.scope('a'); 119 | scopedActions.testActionA(); 120 | scopedActions.testActionA(); 121 | scopedActions.testActionA(); 122 | scopedActions.testActionB(TEST_VALUE); 123 | expect(store.getState()).toEqual({ 124 | a: 0, 125 | b: null 126 | }); 127 | releaseAllActions(); 128 | expect(store.getState()).toEqual({ 129 | a: 3, 130 | b: TEST_VALUE 131 | }); 132 | }); 133 | 134 | it('throws on a null or undefined actionFactories', () => { 135 | expect(() => { 136 | bindScopedActionFactories(null, store.dispatch); 137 | }).toThrow( 138 | 'bindScopedActionFactories expected an object or a function ' + 139 | 'instead of null' 140 | ); 141 | expect(() => { 142 | bindScopedActionFactories(undefined, store.dispatch); 143 | }).toThrow( 144 | 'bindScopedActionFactories expected an object or a function ' + 145 | 'instead of undefined' 146 | ); 147 | expect(() => { 148 | bindScopedActionFactories({ foo: null }, store.dispatch); 149 | }).toThrow( 150 | 'bindScopedActionFactories expected an object or a function ' + 151 | 'instead of null' 152 | ); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/helpers/actions.js: -------------------------------------------------------------------------------- 1 | export const TEST_ACTION_A = Symbol('TEST_ACTION_A'); 2 | export const TEST_ACTION_B = Symbol('TEST_ACTION_B'); 3 | 4 | export default { 5 | testActionA: () => ({ type: TEST_ACTION_A }), 6 | testActionB: (value) => ({ type: TEST_ACTION_B, value }) 7 | }; 8 | -------------------------------------------------------------------------------- /test/helpers/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { TEST_ACTION_A, TEST_ACTION_B } from './actions'; 3 | 4 | export const reduceA = (state = 0, action = {}) => { 5 | switch (action.type) { 6 | case TEST_ACTION_A: 7 | return state + 1; 8 | default: 9 | return state; 10 | } 11 | }; 12 | export const reduceB = (state = null, action = {}) => { 13 | switch (action.type) { 14 | case TEST_ACTION_B: 15 | return action.value; 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | export default combineReducers({ 22 | a: reduceA, 23 | b: reduceB 24 | }); 25 | -------------------------------------------------------------------------------- /test/scopeActionCreators.spec.js: -------------------------------------------------------------------------------- 1 | import * as object from '../src/utils/object-shim'; 2 | import { scopeActionCreators } from '../src'; 3 | import actionCreators from './helpers/actions'; 4 | 5 | const { testActionA, testActionB } = actionCreators; 6 | 7 | describe('scopeActionCreators', () => { 8 | let testScopes; 9 | let TEST_VALUE; 10 | let unscopedActionA; 11 | let unscopedActionB; 12 | beforeEach(() => { 13 | testScopes = ['x', 'y', 'z', '4']; 14 | TEST_VALUE = Symbol('TEST_VALUE'); 15 | unscopedActionA = testActionA(); 16 | unscopedActionB = testActionB(TEST_VALUE); 17 | }); 18 | 19 | 20 | it('adds a scopeID to the result of an action creator', () => { 21 | testScopes.forEach(scope => { 22 | const scopedCreatorA = scopeActionCreators(testActionA, scope); 23 | const scopedCreatorB = scopeActionCreators(testActionB, scope); 24 | 25 | const scopedActionA = scopedCreatorA(); 26 | const scopedActionB = scopedCreatorB(TEST_VALUE); 27 | 28 | expect(scopedActionA).toEqual({ 29 | ...unscopedActionA, 30 | scopeID: scope 31 | }); 32 | expect(scopedActionB).toEqual({ 33 | ...unscopedActionB, 34 | scopeID: scope 35 | }); 36 | }); 37 | }); 38 | 39 | it('adds a scopeID to the result of an object of action creators', () => { 40 | testScopes.forEach(scope => { 41 | const scopedCreators = scopeActionCreators(actionCreators, scope); 42 | 43 | object.entries(scopedCreators).forEach(([key, creator]) => { 44 | if (key === '__scope__') { 45 | return; 46 | } 47 | const unscopedAction = actionCreators[key](TEST_VALUE); 48 | const scopedAction = creator(TEST_VALUE); 49 | expect(scopedAction).toEqual({ 50 | ...unscopedAction, 51 | scopeID: scope 52 | }); 53 | }); 54 | }); 55 | }); 56 | 57 | it('throws on a null or undefined ID', () => { 58 | const testCases = [testActionA, actionCreators]; 59 | testCases.forEach(testCase => { 60 | expect(() => { 61 | scopeActionCreators(testCase, null); 62 | }).toThrow( 63 | 'scopeActionCreators cannot scope for an id of null' 64 | ); 65 | expect(() => { 66 | scopeActionCreators(testCase, undefined); 67 | }).toThrow( 68 | 'scopeActionCreators cannot scope for an id of undefined' 69 | ); 70 | }); 71 | }); 72 | 73 | it('throws on a non-string or non-number ID', () => { 74 | const testCases = [testActionA, actionCreators]; 75 | testCases.forEach(testCase => { 76 | expect(() => { 77 | scopeActionCreators(testCase, Symbol('Test')); 78 | }).toThrow( 79 | 'scopeActionCreators cannot scope for an id of Symbol(Test)' 80 | ); 81 | expect(() => { 82 | scopeActionCreators(testCase, {}); 83 | }).toThrow( 84 | 'scopeActionCreators cannot scope for an id of [object Object]' 85 | ); 86 | }); 87 | }); 88 | 89 | it('skips non-function or non-object values in the object', () => { 90 | const testCase = { 91 | ...actionCreators, 92 | foo: 42, 93 | bar: 'baz', 94 | wow: undefined, 95 | test: null 96 | }; 97 | testScopes.forEach(scope => { 98 | const scopedCreators = scopeActionCreators(testCase, scope); 99 | expect( 100 | Object.keys(scopedCreators).sort() 101 | ).toEqual( 102 | Object.keys(actionCreators).concat('__scope__').sort() 103 | ); 104 | }); 105 | }); 106 | 107 | it('scopes deeply for nested actionCreators', () => { 108 | const testCase = { 109 | ...actionCreators, 110 | nested: actionCreators 111 | }; 112 | testScopes.forEach(scope => { 113 | function testCreator(creator, key, unscopedCreators) { 114 | const unscopedAction = unscopedCreators[key](TEST_VALUE); 115 | const scopedAction = creator(TEST_VALUE); 116 | expect(scopedAction).toEqual({ 117 | ...unscopedAction, 118 | scopeID: scope 119 | }); 120 | } 121 | const scopedTestCase = scopeActionCreators(testCase, scope); 122 | object.entries(scopedTestCase).forEach(([key, creator]) => { 123 | if (key === '__scope__') { 124 | return; 125 | } 126 | if (typeof creator === 'function') { 127 | testCreator(creator, key, testCase); 128 | } else if (typeof creator === 'object') { 129 | object.entries(creator).forEach(([key, nestedCreator]) => { 130 | testCreator(nestedCreator, key, creator); 131 | }); 132 | } 133 | }); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/scopeReducers.spec.js: -------------------------------------------------------------------------------- 1 | import { scopeActionCreators, scopeReducers } from '../src'; 2 | import actionCreators from './helpers/actions'; 3 | import reducers, { reduceA, reduceB } from './helpers/reducers'; 4 | 5 | describe('scopeActionCreators', () => { 6 | let testScopes; 7 | let TEST_VALUE; 8 | beforeEach(() => { 9 | testScopes = ['x', 'y', 'z', 4]; 10 | TEST_VALUE = Symbol('TEST_VALUE'); 11 | }); 12 | 13 | it('routes state changes only to scope-gated parts of the state', () => { 14 | testScopes.forEach(scope => { 15 | const initialState = testScopes.reduce((result, s) => ({ 16 | ...result, 17 | [s]: reducers() 18 | }), {}); 19 | const scopedActions = scopeActionCreators(actionCreators, scope); 20 | const reduce = scopeReducers({ 21 | a: reduceA, 22 | b: reduceB 23 | }); 24 | const firstResult = reduce( 25 | initialState, scopedActions.testActionB(TEST_VALUE) 26 | ); 27 | const result = reduce(firstResult, scopedActions.testActionA()); 28 | expect(result).toEqual({ 29 | ...initialState, 30 | [scope]: { 31 | a: 1, 32 | b: TEST_VALUE 33 | } 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import path from 'path'; 3 | 4 | const { NODE_ENV } = process.env; 5 | 6 | const plugins = [ 7 | new webpack.optimize.OccurenceOrderPlugin(), 8 | new webpack.DefinePlugin({ 9 | 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), 10 | }), 11 | ]; 12 | 13 | const filename = `redux-doghouse${NODE_ENV === 'production' ? '.min' : ''}.js`; 14 | 15 | NODE_ENV === 'production' && plugins.push( 16 | new webpack.optimize.UglifyJsPlugin({ 17 | compressor: { 18 | pure_getters: true, 19 | unsafe: true, 20 | unsafe_comps: true, 21 | screw_ie8: true, 22 | warnings: false, 23 | }, 24 | }) 25 | ); 26 | 27 | export default { 28 | module: { 29 | loaders: [ 30 | { test: /\.js$/, loaders: ['babel-loader'], exclude: /node_modules/ }, 31 | ], 32 | }, 33 | 34 | entry: [ 35 | './src/index', 36 | ], 37 | 38 | output: { 39 | path: path.join(__dirname, 'dist'), 40 | filename, 41 | library: 'ReduxDoghouse', 42 | libraryTarget: 'umd', 43 | }, 44 | 45 | plugins, 46 | }; 47 | --------------------------------------------------------------------------------