├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── examples └── simple │ ├── .babelrc │ ├── components │ ├── App.js │ └── CounterPanel │ │ ├── CounterPanel.js │ │ ├── index.js │ │ └── redux │ │ ├── CounterState.js │ │ ├── LogMiddleware.js │ │ ├── actionTypes.js │ │ └── actions.js │ ├── index.html │ ├── index.js │ ├── package.json │ ├── reducers │ ├── appInfo.js │ └── index.js │ ├── server.js │ └── webpack.config.js ├── package.json ├── src ├── actionTypes.js ├── componentState.js ├── componentStateStore.js ├── index.js └── utils │ ├── composeActionCreators.js │ ├── filters.js │ ├── getDisplayName.js │ ├── index.js │ ├── validateConfig.js │ └── validateSubscription.js ├── test ├── .eslintrc ├── componentState.spec.js ├── componentStateStore.spec.js ├── helpers │ ├── actionCreators.js │ ├── actionTypes.js │ ├── document.js │ ├── middleware.js │ └── reducers.js └── utils │ └── document.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | **/node_modules 3 | **/webpack.config.js 4 | examples/**/server.js 5 | examples/simple/node_modules 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "react/jsx-uses-react": 2, 10 | "react/jsx-uses-vars": 2, 11 | "react/react-in-jsx-scope": 2 12 | }, 13 | "plugins": [ 14 | "react" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | dist 5 | lib 6 | coverage 7 | .vimsession 8 | .lvimrc 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | src 4 | test 5 | examples 6 | coverage 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "iojs" 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | All notable changes to this project will be documented using the 4 | [Releases page](https://github.com/cef62/redux-component-state/releases). 5 | This project adheres to [Semantic Versioning](http://semver.org/). 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Matteo Ronchi 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-component-state 2 | 3 | [![Build Status](https://travis-ci.org/cef62/redux-component-state.svg?branch=master)](https://travis-ci.org/cef62/redux-component-state) 4 | [![npm version](https://img.shields.io/npm/v/redux-component-state.svg?style=flat-square)](https://www.npmjs.com/package/redux-component-state) 5 | 6 | Component level state's manager using redux reducers to support on-demand store creation. 7 | 8 | This project is born to satisfy some requirement for an internal app. We needed to create and destroy store fragments on-demand for some specific component. Initially we tried to define specific component reducers, registering them at application start. It worked fine but we not liked define component specific details at application level. 9 | Reducer and action creators of a component state should be isolated and available only to its owner component. 10 | 11 | [This discussion](https://github.com/rackt/redux/issues/159) is related with our requirements and the initial proposal of Dan Abramov (@gaeron) and the experiment from Taylor Hakes (@taylorhakes) helped out to create this project. 12 | Thanks to both! 13 | 14 | The project is in its initial state and lot of work still needs to be done but should be fairly safe to use. We use it in production in a small app and at the moment we haven't found problems. 15 | 16 | ## Todos 17 | 18 | - [ ] add complete test coverage (currently WIP) 19 | - [ ] add usage examples 20 | - [ ] add jsdocs and remove verbose code comments 21 | - [ ] improve subscription object API: 22 | - [ ] add reset() method 23 | - [ ] separate `componentStateStore` and `componentState` HoC in different projects 24 | to permit use of component states with other libraries than React 25 | - [ ] further investigate if the current approach bring performance downsides 26 | 27 | ## Install 28 | 29 | Install it via npm: `npm install --save redux-component-state`. 30 | 31 | ## How to use 32 | 33 | Waiting to better introduction look the example code. 34 | -------------------------------------------------------------------------------- /examples/simple/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all" 4 | } 5 | -------------------------------------------------------------------------------- /examples/simple/components/App.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | import {createStore, combineReducers, compose} from 'redux'; 4 | import {Provider} from 'react-redux'; 5 | 6 | import {reduxComponentStateStore} from 'redux-component-state'; 7 | 8 | import appInfo from '../reducers/appInfo'; 9 | import CounterPanel from './CounterPanel'; 10 | 11 | const combinedReducers = combineReducers({ appInfo }); 12 | const createFinalStore = compose(reduxComponentStateStore())(createStore); 13 | const store = createFinalStore(combinedReducers); 14 | 15 | export default class App extends Component { 16 | render() { 17 | return ( 18 | 19 | {() => } 20 | 21 | ); 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /examples/simple/components/CounterPanel/CounterPanel.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | import reduxComponentState from 'redux-component-state'; 4 | 5 | import * as compActions from './redux/actions'; 6 | import CounterState from './redux/CounterState'; 7 | import LogMiddleware from './redux/LogMiddleware'; 8 | 9 | /** By default mapStateToProps is not required. The default version 10 | * mimics this example returning the private store slice. 11 | * Can be used to pass more complex selectors to the component state 12 | * COnnect instance. 13 | */ 14 | function defaultMapStateToProps(key) { 15 | return (state) => Object.assign( {}, state[key] ); 16 | } 17 | 18 | class CounterPanel extends Component { 19 | 20 | static propTypes = { 21 | compActions: PropTypes.object.isRequired, 22 | getComponentState: PropTypes.func.isRequired, 23 | counter: PropTypes.shape({ 24 | value: PropTypes.number.isRequired, 25 | interactionCount: PropTypes.number.isRequired, 26 | }).isRequired, 27 | } 28 | 29 | static defaultProps = {} 30 | 31 | render() { 32 | const { compActions: actions, counter } = this.props; 33 | const { increment, decrement } = actions; 34 | 35 | return ( 36 |
37 | Current Count: {counter.value}
38 | Number of interactions: {counter.interactionCount} 39 |
40 | 41 | 42 |
43 | ); 44 | } 45 | } 46 | 47 | const componentStateConfig = { 48 | getKey(props) { 49 | const { id = 'defaultCounterPanel' } = props; 50 | return `counter-${id}`; 51 | }, 52 | reducer: CounterState, 53 | actions: { compActions }, 54 | middlewares: [ LogMiddleware ], 55 | mapStateToProps: defaultMapStateToProps, 56 | }; 57 | export default reduxComponentState(componentStateConfig)(CounterPanel); 58 | -------------------------------------------------------------------------------- /examples/simple/components/CounterPanel/index.js: -------------------------------------------------------------------------------- 1 | import CounterPanel from './CounterPanel'; 2 | 3 | export default CounterPanel; 4 | -------------------------------------------------------------------------------- /examples/simple/components/CounterPanel/redux/CounterState.js: -------------------------------------------------------------------------------- 1 | import combineReducers from 'redux/lib/utils/combineReducers'; 2 | import { 3 | INCREMENT, 4 | DECREMENT 5 | } from './actionTypes'; 6 | 7 | const initialState = { 8 | value: 0, 9 | interactionCount: 0, 10 | }; 11 | 12 | function update({value, interactionCount}, action) { 13 | const newInteractionCount = interactionCount + 1; 14 | const newValue = value + action.value; 15 | return { value: newValue, interactionCount: newInteractionCount }; 16 | } 17 | 18 | function counter(state = initialState, action) { 19 | let newState; 20 | switch (action.type) { 21 | case INCREMENT: 22 | case DECREMENT: 23 | newState = update(state, action); 24 | break; 25 | 26 | default: 27 | newState = state; 28 | } 29 | return newState; 30 | } 31 | 32 | const CounterState = combineReducers({ counter }); 33 | export default CounterState; 34 | -------------------------------------------------------------------------------- /examples/simple/components/CounterPanel/redux/LogMiddleware.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-vars:0 no-console:0*/ 2 | export default function logMiddleware({dispatch, getState}) { 3 | return next => action => { 4 | const { type } = action; 5 | console.log(`Middleware Logger. type: ${type}`); 6 | return next(action); 7 | }; 8 | } 9 | 10 | -------------------------------------------------------------------------------- /examples/simple/components/CounterPanel/redux/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const INCREMENT = 'counter-panel/increment'; 2 | export const DECREMENT = 'counter-panel/decrement'; 3 | -------------------------------------------------------------------------------- /examples/simple/components/CounterPanel/redux/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | INCREMENT, 3 | DECREMENT 4 | } from './actionTypes'; 5 | 6 | export function increment(multiplier = 1) { 7 | return { 8 | type: INCREMENT, 9 | value: multiplier, 10 | }; 11 | } 12 | 13 | export function decrement(multiplier = 1) { 14 | return { 15 | type: DECREMENT, 16 | value: (-multiplier), 17 | }; 18 | } 19 | 20 | -------------------------------------------------------------------------------- /examples/simple/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | redux-component-state-example-title 4 | 5 | 6 |
7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/simple/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './components/App'; 3 | 4 | React.render( 5 | , 6 | document.getElementById('root') 7 | ); 8 | -------------------------------------------------------------------------------- /examples/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-component-state-example", 3 | "version": "0.0.0", 4 | "description": "simple example of use of redux-component-state", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/cef62/redux-component-state.git" 12 | }, 13 | "keywords": [ 14 | "redux", 15 | "flux", 16 | "react", 17 | "decorator", 18 | "reducer" 19 | ], 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/cef62/redux-component-state/issues" 23 | }, 24 | "homepage": "https://github.com/cef62/redux-component-state", 25 | "dependencies": {}, 26 | "devDependencies": { 27 | "babel-core": "5.8.22", 28 | "babel-loader": "5.1.4", 29 | "node-libs-browser": "0.5.2", 30 | "react-hot-loader": "1.2.7", 31 | "webpack": "1.9.11", 32 | "webpack-dev-server": "1.9.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/simple/reducers/appInfo.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | name: 'Simple Component State', 3 | }; 4 | 5 | export default function(state = initialState) { 6 | return state; 7 | } 8 | -------------------------------------------------------------------------------- /examples/simple/reducers/index.js: -------------------------------------------------------------------------------- 1 | import appInfo from './appInfo'; 2 | 3 | export default { appInfo }; 4 | -------------------------------------------------------------------------------- /examples/simple/server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | var config = require('./webpack.config'); 4 | 5 | new WebpackDevServer(webpack(config), { 6 | publicPath: config.output.publicPath, 7 | hot: true, 8 | historyApiFallback: true, 9 | stats: { 10 | colors: true 11 | } 12 | }).listen(3001, 'localhost', function (err) { 13 | if (err) { 14 | console.log(err); 15 | } 16 | 17 | console.log('Listening at localhost:3001'); 18 | }); 19 | -------------------------------------------------------------------------------- /examples/simple/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'source-maps', 6 | entry: [ 7 | 'webpack-dev-server/client?http://localhost:3001', 8 | 'webpack/hot/only-dev-server', 9 | './index' 10 | ], 11 | output: { 12 | path: path.join(__dirname, 'dist'), 13 | filename: 'bundle.js', 14 | publicPath: '/static/' 15 | }, 16 | plugins: [ 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin() 19 | ], 20 | resolve: { 21 | alias: { 22 | 'redux-component-state': path.join(__dirname, '..', '..', 'src') 23 | }, 24 | extensions: ['', '.js'] 25 | }, 26 | module: { 27 | loaders: [{ 28 | test: /\.js$/, 29 | loaders: ['react-hot', 'babel'], 30 | exclude: /node_modules/, 31 | include: __dirname 32 | }, { 33 | test: /\.js$/, 34 | loaders: ['babel'], 35 | include: path.join(__dirname, '..', '..', 'src') 36 | }] 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-component-state", 3 | "version": "0.3.2", 4 | "description": "Manage dynamic reducers specific for a component or a set of components", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rimraf lib dist", 8 | "build": "babel src --out-dir lib", 9 | "build:umd": "webpack src/index.js dist/redux-component-state.js && NODE_ENV=production webpack src/index.js dist/redux-component-state.min.js", 10 | "lint": "eslint src test examples", 11 | "test": "NODE_ENV=test babel-node ./node_modules/.bin/tape test/**/*.spec.js | faucet", 12 | "test:watch": "watch 'npm run test' test", 13 | "test:cov": "./node_modules/.bin/browserify --bare -t babelify -t coverify ./test/*.spec.js | node | ./node_modules/.bin/coverify", 14 | "prepublish": "npm run lint && npm run test && npm run clean && npm run build && npm run build:umd" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/cef62/redux-component-state.git" 19 | }, 20 | "keywords": [ 21 | "redux", 22 | "flux", 23 | "react", 24 | "decorator", 25 | "reducer" 26 | ], 27 | "author": "Matteo Ronchi", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/cef62/redux-component-state/issues" 31 | }, 32 | "homepage": "https://github.com/cef62/redux-component-state", 33 | "devDependencies": { 34 | "babel": "5.8.21", 35 | "babel-core": "5.8.22", 36 | "babel-eslint": "4.1.1", 37 | "babel-loader": "5.1.4", 38 | "babelify": "6.3.0", 39 | "browserify": "11.1.0", 40 | "coverify": "1.4.1", 41 | "eslint": "1.4.1", 42 | "eslint-config-airbnb": "0.0.8", 43 | "eslint-plugin-react": "3.1.0", 44 | "faucet": "0.0.1", 45 | "isparta": "3.0.3", 46 | "react": "0.13.3", 47 | "react-redux": "2.0.0", 48 | "redux": "2.0.0", 49 | "rimraf": "2.3.4", 50 | "sinon": "1.16.1", 51 | "tape": "4.2.0", 52 | "watch": "0.16.0", 53 | "webpack": "1.9.6", 54 | "webpack-dev-server": "1.8.2" 55 | }, 56 | "dependencies": {}, 57 | "peerDependencies": { 58 | "react": "^0.13.3", 59 | "redux": "^2.0.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const STATE_ACTION = 'redux-cs/state'; 2 | export const MOUNT = 'redux-cs/mount'; 3 | export const UNMOUNT = 'redux-cs/unmount'; 4 | export const INIT = 'redux-cs/init'; 5 | export const ACTION = 'redux-cs/action'; 6 | export const KEY = 'redux-cs/'; 7 | -------------------------------------------------------------------------------- /src/componentState.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {composeActionCreators, validateConfig, getDisplayName} from './utils'; 4 | 5 | function defaultMapStateToProps(key) { 6 | return (state) => Object.assign( {}, state[key] ); 7 | } 8 | 9 | export default function reduxComponentState(componentStoreConfig) { 10 | validateConfig(componentStoreConfig); 11 | 12 | return (DecoratedComponent) => 13 | class ReduxComponentState extends Component { 14 | 15 | static displayName = `ReduxComponentState(${getDisplayName(DecoratedComponent)})`; 16 | static DecoratedComponent = DecoratedComponent; 17 | 18 | static contextTypes = { 19 | store: PropTypes.shape({ 20 | componentState: PropTypes.shape({ 21 | subscribe: PropTypes.func.isRequired, 22 | }).isRequired, 23 | }), 24 | }; 25 | 26 | constructor(props, context) { 27 | super(props, context); 28 | this.dispatchToState = this.dispatchToState.bind(this); 29 | } 30 | 31 | componentWillMount() { 32 | const { 33 | getKey, 34 | reducer, 35 | getInitialState, 36 | actions, 37 | middlewares, 38 | mapStateToProps, 39 | } = componentStoreConfig; 40 | 41 | const initialState = (getInitialState || (() => undefined))(this.props); 42 | 43 | this.subscription = this.context.store.componentState.subscribe({ 44 | key: getKey(this.props), 45 | reducer, 46 | initialState, 47 | middlewares, 48 | }); 49 | 50 | this.boundActionCreators = composeActionCreators(actions, this.subscription.dispatch); 51 | this.ReduxConnectWrapper = this.createReduxConnector(this.subscription.storeKey, mapStateToProps); 52 | } 53 | 54 | componentWillUnmount() { 55 | this.subscription.unsubscribe(); 56 | Reflect.deleteProperty(this, 'subscription'); 57 | } 58 | 59 | createReduxConnector(key, mapStateToProps = defaultMapStateToProps) { 60 | return connect( mapStateToProps(key) )(DecoratedComponent); 61 | } 62 | 63 | dispatchToState(action) { 64 | return this.subscription.dispatch(action); 65 | } 66 | 67 | render() { 68 | const childProps = Object.assign( {}, 69 | this.props, { 70 | ref: 'ReduxComponentStateConnector', 71 | dispatchToState: this.dispatchToState, 72 | getComponentState: this.subscription.getState, 73 | }, 74 | this.boundActionCreators 75 | ); 76 | 77 | return React.createElement(this.ReduxConnectWrapper, childProps); 78 | } 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/componentStateStore.js: -------------------------------------------------------------------------------- 1 | import { 2 | STATE_ACTION, 3 | INIT, 4 | MOUNT, 5 | UNMOUNT, 6 | ACTION, 7 | KEY 8 | } from './actionTypes'; 9 | import { compose } from 'redux'; 10 | import validateSubscription from './utils/validateSubscription'; 11 | 12 | export default function createComponentStateStore(...middlewares) { 13 | if ( middlewares.some(middleware => typeof middleware !== 'function') ) { 14 | throw new Error('Redux Component State Store accept only function as parameters'); 15 | } 16 | 17 | return (next) => { 18 | // map of component state subscribers 19 | const subscribersMap = {}; 20 | 21 | // redux store 22 | let store; 23 | 24 | // **************************************************************** 25 | // getState method, 26 | // the state is sliced with only the specific component state of 27 | // the subscriber 28 | // **************************************************************** 29 | 30 | function getStateKey(key) { 31 | return KEY + key; 32 | } 33 | 34 | function getState(key) { 35 | if (!subscribersMap[key]) { 36 | throw new Error(`redux-component-state, trying to retrieve state for an unknown subscriber: ${key}`); 37 | } 38 | return store.getState()[key]; 39 | } 40 | // **************************************************************** 41 | // dispatch method for component state actions 42 | // **************************************************************** 43 | 44 | function dispatch(key, action) { 45 | if (!subscribersMap[key]) { 46 | throw new Error(`redux-component-state, trying to dispatch actions for an unknown subscriber: ${key}`); 47 | } 48 | 49 | return store.dispatch({ 50 | type: STATE_ACTION, 51 | subType: ACTION, 52 | key: key, 53 | data: action, 54 | }); 55 | } 56 | 57 | // **************************************************************** 58 | // unsubscribe method, 59 | // passed upon the subscription 60 | // **************************************************************** 61 | 62 | function unsubscribe(key) { 63 | // unmount the component store 64 | store.dispatch({ type: STATE_ACTION, subType: UNMOUNT, key }); 65 | 66 | // clear subscriber data 67 | Reflect.deleteProperty(subscribersMap, key); 68 | } 69 | 70 | // **************************************************************** 71 | // subscribe method, 72 | // passed to children via `store context` 73 | // **************************************************************** 74 | 75 | function subscribe(subscription) { 76 | validateSubscription(subscription); 77 | 78 | const { 79 | key, 80 | reducer, 81 | initialState, 82 | middlewares: componentMiddlewares = [], 83 | } = subscription; 84 | 85 | // compose unique store-key 86 | const storeKey = getStateKey(key); 87 | 88 | if (subscribersMap[storeKey]) { 89 | throw new Error(`The redux componentStore with key: ${key} is already registered!`); 90 | } 91 | 92 | // create redux reducer function 93 | subscribersMap[storeKey] = reducer; 94 | 95 | const middlewareAPI = { 96 | getState: getState.bind(null, storeKey), 97 | dispatch: (action) => dispatch(storeKey, action), 98 | }; 99 | 100 | const chain = middlewares.concat(componentMiddlewares) 101 | .map(middleware => middleware(middlewareAPI)); 102 | 103 | const componentStateDispatch = dispatch.bind(null, storeKey); 104 | const enhancedDispatch = compose(...chain)(componentStateDispatch); 105 | 106 | // mount the new state on the redux store 107 | store.dispatch({ 108 | type: STATE_ACTION, 109 | subType: MOUNT, 110 | key: storeKey, 111 | state: initialState, 112 | }); 113 | 114 | // return unsuscriber function 115 | return { 116 | storeKey, 117 | dispatch: enhancedDispatch, 118 | unsubscribe: unsubscribe.bind(null, storeKey), 119 | getState: getState.bind(null, storeKey), 120 | }; 121 | } 122 | 123 | // **************************************************************** 124 | // Reducer wrapper function, 125 | // use internally to create a component state reducer 126 | // **************************************************************** 127 | 128 | /* 129 | * Component State Action Signature: 130 | * { 131 | * key: {required} {string}, 132 | * type: {required} {string}, 133 | * subType: {required} {string}, 134 | * state: {optional} {object} pre-composed state to map to the component 135 | * state, used to override initial component state mounting default 136 | * value 137 | * data: {required for subtype === ACTION } original action submitted 138 | * } 139 | */ 140 | const reactions = { 141 | [MOUNT]: (state, action, reducer) => { 142 | state[action.key] = action.state || reducer(undefined, { type: INIT }); 143 | return state; 144 | }, 145 | 146 | [UNMOUNT]: (state, action) => { 147 | delete state[action.key]; 148 | return state; 149 | }, 150 | 151 | [ACTION]: (state, action, reducer) => { 152 | state[action.key] = reducer(state[action.key], action.data); 153 | return state; 154 | }, 155 | }; 156 | 157 | function applyComponentStateReducers(action, state = {}, newState) { 158 | const { key, type, subType } = action; 159 | const reducer = subscribersMap[key]; 160 | const isComponentStateAction = type === STATE_ACTION && reducer; 161 | let tmpState; 162 | 163 | tmpState = Object.keys(subscribersMap).reduce( (acc, storeKey) => { 164 | const currState = state[storeKey]; 165 | 166 | if (isComponentStateAction) { 167 | acc[storeKey] = currState; 168 | } else if (currState) { 169 | const reduced = subscribersMap[storeKey](currState, action); 170 | const changed = Object.keys(reduced).some( (sub) => currState[sub] !== reduced[sub] ); 171 | if (!changed) { 172 | acc[storeKey] = currState; 173 | } else if (reduced) { 174 | acc[storeKey] = reduced; 175 | } 176 | } 177 | return acc; 178 | }, {}); 179 | 180 | if (isComponentStateAction) { 181 | tmpState[key] = tmpState[key] || {}; 182 | 183 | const reaction = reactions[subType] || ( (cs) => cs ); 184 | reaction(tmpState, action, reducer); 185 | 186 | const refState = state[key] || {}; 187 | const newTmpState = tmpState[key]; 188 | if (newTmpState) { 189 | const stateChanged = Object.keys(newTmpState).some( (sub) => refState[sub] !== newTmpState[sub] ); 190 | if (!stateChanged) { 191 | tmpState[key] = refState; 192 | } 193 | } 194 | } 195 | 196 | Object.assign(newState, tmpState); 197 | return newState; 198 | } 199 | 200 | function componentStateReducer(reducer) { 201 | // `reducer` is the received original redux store reducer function 202 | // return reducer method signature 203 | return (state, action) => { 204 | // process action with the original reducer 205 | let newState = reducer(state, action); 206 | newState = applyComponentStateReducers(action, state, newState); 207 | return newState; 208 | }; 209 | } 210 | 211 | // **************************************************************** 212 | // returned Higher Order Store Creator function 213 | // **************************************************************** 214 | 215 | return function componentStateStore(reducer, initialState) { 216 | // keep reference to the redux store 217 | store = next(componentStateReducer(reducer), initialState); 218 | 219 | // return composed store. 220 | // The replaceReducer is wrapped to use the component store implementation. 221 | // A `componentState` field is added to the main redux store. 222 | return { 223 | ...store, 224 | replaceReducer: (reducerFunc) => store.replaceReducer(componentStateReducer(reducerFunc)), 225 | componentState: { 226 | subscribe, 227 | }, 228 | }; 229 | }; 230 | }; 231 | } 232 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import reduxComponentStateStore from './componentStateStore'; 2 | import reduxComponentState from './componentState'; 3 | 4 | export default reduxComponentState; 5 | export { 6 | reduxComponentStateStore 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/composeActionCreators.js: -------------------------------------------------------------------------------- 1 | import {bindActionCreators} from 'redux'; 2 | import { filterPrivateField } from './filters'; 3 | 4 | export default function composeActionCreators(actions, dispatch) { 5 | let boundACs; 6 | if (actions) { 7 | const keys = Object.keys(actions).filter(filterPrivateField); 8 | if (keys.length) { 9 | if (typeof actions[keys[0]] === 'function') { 10 | // single map of ACs 11 | boundACs = bindActionCreators(actions, dispatch); 12 | } else { 13 | // nested map of ACs 14 | boundACs = keys.reduce( (acc, key) => { 15 | acc[key] = bindActionCreators(actions[key], dispatch); 16 | return acc; 17 | }, {}); 18 | } 19 | } 20 | } 21 | return boundACs; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/utils/filters.js: -------------------------------------------------------------------------------- 1 | const BLOCKED_PREFIX = '__'; 2 | export const filterPrivateField = field => !field.startsWith(BLOCKED_PREFIX); 3 | -------------------------------------------------------------------------------- /src/utils/getDisplayName.js: -------------------------------------------------------------------------------- 1 | export default function getDisplayName(Comp) { 2 | return Comp.displayName || Comp.name || 'Component'; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import getDisplayName from './getDisplayName'; 2 | import validateConfig from './validateConfig'; 3 | import composeActionCreators from './composeActionCreators'; 4 | 5 | export default { 6 | getDisplayName, 7 | validateConfig, 8 | composeActionCreators, 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/validateConfig.js: -------------------------------------------------------------------------------- 1 | import isPlainObject from 'redux/lib/utils/isPlainObject'; 2 | import { filterPrivateField } from './filters'; 3 | /* 4 | let config = { 5 | getKey: PropTypes.func.isRequired, 6 | reducers: PropTypes.object.isRequired, 7 | getInitialState: PropTypes.func, 8 | actions: PropTypes.object, 9 | middlewares: Proptypes.array, 10 | }; 11 | */ 12 | export default function validateConfig(config) { 13 | const prefix = 'Redux Component State'; 14 | 15 | if (!config) { 16 | throw new Error(`${prefix} requires a configuration object.`); 17 | } 18 | 19 | const { getKey, reducer, actions, middlewares } = config; 20 | 21 | if (typeof getKey !== 'function' ) { 22 | throw new Error(`${prefix} requires a getKey() function.`); 23 | } 24 | 25 | if (typeof reducer !== 'function' ) { 26 | throw new Error(`${prefix} expected the reducer to be a function.`); 27 | } 28 | 29 | if (middlewares && !Array.isArray(middlewares)) { 30 | throw new Error(`${prefix} expected middlewares to be an array.`); 31 | } 32 | 33 | if (actions) { 34 | if (!Object.keys(actions).length) { 35 | throw new Error(`${prefix} can't have an empty map of actions`); 36 | } 37 | 38 | const topLevelItem = Object.keys(actions) 39 | .filter(filterPrivateField) 40 | .map(act => actions[act]); 41 | 42 | if ( 43 | !topLevelItem.every( item => typeof item === 'function' ) 44 | && !topLevelItem.every( item => { 45 | if (!isPlainObject(item)) return false; 46 | const keys = Object.keys(item).filter(filterPrivateField); 47 | if (!keys.length) return false; 48 | return keys.every( sub => typeof item[sub] === 'function' ); 49 | }) 50 | ) { 51 | throw new Error(`${prefix} requires an actions map where every key is a function or a map of function.`); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/validateSubscription.js: -------------------------------------------------------------------------------- 1 | /* 2 | let subscriptiom = { 3 | key: PropTypes.string.isRequired, 4 | reducers: PropTypes.object.isRequired, 5 | initialState: PropTypes.object, 6 | middlewares: Proptypes.array, 7 | } 8 | */ 9 | export default function validateSubscription(subscription) { 10 | const prefix = 'Redux Component State Store Subscription'; 11 | 12 | if (!subscription) { 13 | throw new Error(`${prefix} requires a subscription object.`); 14 | } 15 | 16 | const { key, reducer, initialState, middlewares } = subscription; 17 | 18 | if (!key || typeof key !== 'string' || !key.length ) { 19 | throw new Error(`${prefix} requires a valid key identifier.`); 20 | } 21 | 22 | if (typeof reducer !== 'function' ) { 23 | throw new Error(`${prefix} expected the reducer to be a function.`); 24 | } 25 | 26 | if (middlewares && !Array.isArray(middlewares)) { 27 | throw new Error(`${prefix} expected middlewares to be an array.`); 28 | } 29 | 30 | if (initialState) { 31 | const reducerStructure = reducer(undefined, {}); 32 | const requiredStates = Object.keys(reducerStructure).filter( red => !red.startsWith('_') ); 33 | const states = Object.keys(initialState).filter( prop => !prop.startsWith('_') ); 34 | const missingInitialStates = states.filter( 35 | st => !requiredStates.find( ref => ref === st ) ); 36 | 37 | if (missingInitialStates.length) { 38 | throw new Error(`${prefix} require a complete initial state for every reducer passed`); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "react/jsx-uses-react": 2, 10 | "react/jsx-uses-vars": 2, 11 | "react/react-in-jsx-scope": 2, 12 | "no-unused-expressions": 0 13 | }, 14 | "plugins": [ 15 | "react" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /test/componentState.spec.js: -------------------------------------------------------------------------------- 1 | require('babel/polyfill'); 2 | 3 | import test from 'tape'; 4 | 5 | import reduxComponentState from '../src'; 6 | // import { createStore, combineReducers, compose } from 'redux'; 7 | // import { addTodo } from './helpers/actionCreators'; 8 | // import * as reducers from './helpers/reducers'; 9 | 10 | const space = ' '; 11 | 12 | test('reduxComponentState', ({end}) => end() ); 13 | test(`.${space}should be a function`, ({ok, equal, end}) => { 14 | ok(reduxComponentState, 'should exists'); 15 | equal(typeof reduxComponentState, 'function'); 16 | 17 | end(); 18 | }); 19 | -------------------------------------------------------------------------------- /test/componentStateStore.spec.js: -------------------------------------------------------------------------------- 1 | require('babel/polyfill'); 2 | 3 | import test from 'tape'; 4 | 5 | import { createStore, combineReducers, compose } from 'redux'; 6 | import { reduxComponentStateStore } from '../src'; 7 | import { addTodo } from './helpers/actionCreators'; 8 | import { testMiddleware } from './helpers/middleware'; 9 | import * as reducers from './helpers/reducers'; 10 | 11 | const space = ' '; 12 | const setup = ({ globalMiddlewares = [], localMiddlewares = [] } = {}) => { 13 | // create redux store 14 | const createFinalStore = compose(reduxComponentStateStore(...globalMiddlewares))(createStore); 15 | const store = createFinalStore(combineReducers(reducers)); 16 | 17 | // create redux reducer for component state 18 | const reducer = combineReducers({ todos: reducers.todos }); 19 | 20 | // setup subscription API test variables 21 | const subscribe = store.componentState.subscribe; 22 | const minimalConfig = { 23 | key: '', 24 | reducer, 25 | middlewares: localMiddlewares, 26 | }; 27 | const completeConfig = Object.assign({}, minimalConfig, { 28 | initialState: { 29 | todos: [{ 30 | id: 999, 31 | text: 're-hydratated todo', 32 | }], 33 | }, 34 | middlewares: localMiddlewares, 35 | }); 36 | return {store, reducer, subscribe, minimalConfig, completeConfig}; 37 | }; 38 | 39 | 40 | test('reduxComponentStateStore', ({end}) => end() ); 41 | 42 | test(`.${space}should be a function`, ({ok, equal, end}) => { 43 | ok(reduxComponentStateStore); 44 | equal(typeof reduxComponentStateStore, 'function'); 45 | end(); 46 | }); 47 | 48 | test(`.${space}should accept 0..n middlewares functions as arguments`, ({throws, doesNotThrow, end}) => { 49 | throws( () => reduxComponentStateStore({}) ); 50 | throws( () => reduxComponentStateStore([testMiddleware]) ); 51 | doesNotThrow( () => reduxComponentStateStore(testMiddleware('fix')) ); 52 | doesNotThrow( () => reduxComponentStateStore(testMiddleware('fix'), testMiddleware('nix') ) ); 53 | end(); 54 | }); 55 | 56 | test(`.${space}should expose the public API`, ({equal, end}) => { 57 | const { store } = setup(); 58 | const methods = Object.keys(store); 59 | 60 | equal(methods.length, 5); 61 | ['subscribe', 'dispatch', 'getState', 'replaceReducer'] 62 | .forEach( key => equal(typeof store[key], 'function', `API missing: ${key}`) ); 63 | 64 | equal(typeof store.componentState, 'object', `API missing: componentState`); 65 | equal(typeof store.componentState.subscribe, 'function', `API missing: componentState.subscribe`); 66 | 67 | end(); 68 | }); 69 | 70 | test(`.${space}should require a valid configuration object`, ({throws, doesNotThrow, end}) => { 71 | const { subscribe, minimalConfig, completeConfig } = setup(); 72 | 73 | throws( subscribe ); 74 | throws( () => subscribe({}) ); 75 | throws( () => subscribe({a: 33, b: 'temp'}) ); 76 | 77 | minimalConfig.key = 'keyA'; 78 | doesNotThrow( () => subscribe(minimalConfig) ); 79 | throws( () => subscribe(minimalConfig) ); 80 | 81 | completeConfig.key = 'keyB'; 82 | doesNotThrow( () => subscribe(completeConfig) ); 83 | 84 | minimalConfig.key = 'keyC'; 85 | minimalConfig.middlewares = [testMiddleware('suffix')]; 86 | doesNotThrow( () => subscribe(minimalConfig) ); 87 | 88 | minimalConfig.key = 'keyD'; 89 | minimalConfig.middlewares = {}; 90 | throws( () => subscribe(minimalConfig) ); 91 | 92 | Reflect.deleteProperty(completeConfig.initialState, 'todos'); 93 | throws( () => subscribe(completeConfig) ); 94 | 95 | end(); 96 | }); 97 | 98 | test(`.${space}componentState.subscribe()`, ({end}) => end() ); 99 | 100 | test(`.${space}${space}should return a subscription object`, ({ok, equal, end}) => { 101 | const { subscribe, minimalConfig } = setup(); 102 | minimalConfig.key = 'keyA'; 103 | const subscription = subscribe(minimalConfig); 104 | 105 | ok( subscription ); 106 | equal( typeof subscription, 'object' ); 107 | 108 | end(); 109 | }); 110 | 111 | test(`.${space}${space}returned object should expose the public API`, ({ok, equal, end}) => { 112 | const { subscribe, minimalConfig } = setup(); 113 | minimalConfig.key = 'keyA'; 114 | const subscription = subscribe(minimalConfig); 115 | 116 | const api = Object.keys(subscription); 117 | equal( api.length, 4 ); 118 | 119 | ok( subscription.unsubscribe ); 120 | ok( subscription.dispatch ); 121 | ok( subscription.getState ); 122 | ok( subscription.storeKey ); 123 | 124 | equal( typeof subscription.unsubscribe, 'function' ); 125 | equal( typeof subscription.dispatch, 'function' ); 126 | equal( typeof subscription.getState, 'function' ); 127 | equal( typeof subscription.storeKey, 'string' ); 128 | 129 | end(); 130 | }); 131 | 132 | test(`.${space}${space}should create a new store with name ending with the given subscription key`, ({ok, end}) => { 133 | const { store, subscribe, minimalConfig } = setup(); 134 | const key = 'keyA'; 135 | minimalConfig.key = key; 136 | const subscription = subscribe(minimalConfig); 137 | 138 | const match = Object.keys(store.getState()).find( k => k.endsWith(key) ); 139 | ok( match ); 140 | 141 | const state = store.getState()[subscription.storeKey]; 142 | ok( Array.isArray(state.todos) ); 143 | 144 | end(); 145 | }); 146 | 147 | test(`.${space}${space}should create a new store populated with given initial state`, ({equal, end}) => { 148 | const { subscribe, completeConfig } = setup(); 149 | completeConfig.key = 'keyA'; 150 | const subscription = subscribe(completeConfig); 151 | 152 | const todos = subscription.getState().todos; 153 | equal( todos.length, 1 ); 154 | equal( todos[0].id, completeConfig.initialState.todos[0].id ); 155 | equal( todos[0].text, completeConfig.initialState.todos[0].text ); 156 | 157 | end(); 158 | }); 159 | 160 | test(`.${space}${space}should support middlewares shared between all component states reducers`, ({equal, end}) => { 161 | const suffix = ' tested'; 162 | const { subscribe, minimalConfig } = setup({ 163 | globalMiddlewares: [testMiddleware(suffix)], 164 | }); 165 | const text = 'test todo'; 166 | 167 | minimalConfig.key = 'keyA'; 168 | const subscription = subscribe(minimalConfig); 169 | subscription.dispatch(addTodo(text)); 170 | 171 | const todos = subscription.getState().todos; 172 | equal( todos.length, 1 ); 173 | equal( todos[0].text, text + suffix ); 174 | 175 | end(); 176 | }); 177 | 178 | test(`.${space}${space}should support middlewares specific for a component state reducers`, ({equal, end}) => { 179 | const suffix = ' tested'; 180 | const { subscribe, minimalConfig } = setup({ 181 | localMiddlewares: [testMiddleware(suffix)], 182 | }); 183 | const text = 'test todo'; 184 | 185 | minimalConfig.key = 'keyA'; 186 | const subscription = subscribe(minimalConfig); 187 | subscription.dispatch(addTodo(text)); 188 | 189 | const todos = subscription.getState().todos; 190 | equal( todos.length, 1 ); 191 | equal( todos[0].text, text + suffix ); 192 | 193 | end(); 194 | }); 195 | 196 | test(`.${space}${space}should support middlewares both shared and specific for a component state reducers`, ({equal, end}) => { 197 | const suffix = ' tested'; 198 | const globalSuffix = ' global'; 199 | const { subscribe, minimalConfig } = setup({ 200 | globalMiddlewares: [testMiddleware(globalSuffix)], 201 | localMiddlewares: [testMiddleware(suffix)], 202 | }); 203 | const text = 'test todo'; 204 | 205 | minimalConfig.key = 'keyA'; 206 | const subscription = subscribe(minimalConfig); 207 | subscription.dispatch(addTodo(text)); 208 | 209 | const todos = subscription.getState().todos; 210 | equal( todos.length, 1 ); 211 | equal( todos[0].text, text + globalSuffix + suffix ); 212 | 213 | end(); 214 | }); 215 | 216 | test(`.${space}componentState.unsubscribe()`, ({end}) => end() ); 217 | 218 | test(`.${space}${space}should remove the component store from redux`, ({notOk, end}) => { 219 | const { store, subscribe, minimalConfig } = setup(); 220 | const key = 'keyA'; 221 | minimalConfig.key = key; 222 | const subscription = subscribe(minimalConfig); 223 | subscription.unsubscribe(); 224 | const match = Object.keys(store.getState()).find( k => k.endsWith(key) ); 225 | notOk( match ); 226 | end(); 227 | }); 228 | 229 | 230 | test(`.${space}componentState.dispatch()`, ({end}) => end() ); 231 | 232 | test(`.${space}${space}should update the state assigned to the component`, ({equal, end}) => { 233 | const { store, subscribe, minimalConfig } = setup(); 234 | minimalConfig.key = 'keyA'; 235 | const subscription = subscribe(minimalConfig); 236 | subscription.dispatch(addTodo('new todo')); 237 | 238 | let todos = subscription.getState().todos; 239 | equal( todos.length, 1 ); 240 | equal( todos[0].text, 'new todo' ); 241 | 242 | todos = store.getState()[subscription.storeKey].todos; 243 | equal( todos.length, 1 ); 244 | equal( todos[0].text, 'new todo' ); 245 | 246 | end(); 247 | }); 248 | 249 | test(`.${space}${space}should throw if invoked after unsubscription`, ({throws, end}) => { 250 | const { subscribe, minimalConfig } = setup(); 251 | minimalConfig.key = 'keyA'; 252 | const subscription = subscribe(minimalConfig); 253 | const action = () => subscription.dispatch(addTodo('new todo')); 254 | action(); 255 | subscription.unsubscribe(); 256 | throws( action ); 257 | 258 | end(); 259 | }); 260 | 261 | test(`.${space}componentState.getState()`, ({end}) => end() ); 262 | 263 | test(`.${space}${space}should throw if invoked after unsubscription`, ({throws, ok, end}) => { 264 | const { subscribe, minimalConfig } = setup(); 265 | minimalConfig.key = 'keyA'; 266 | const subscription = subscribe(minimalConfig); 267 | 268 | const state = subscription.getState(); 269 | ok( state ); 270 | ok( Array.isArray(state.todos) ); 271 | 272 | subscription.unsubscribe(); 273 | throws( subscription.getState ); 274 | 275 | end(); 276 | }); 277 | -------------------------------------------------------------------------------- /test/helpers/actionCreators.js: -------------------------------------------------------------------------------- 1 | import { ADD_TODO, DISPATCH_IN_MIDDLE, THROW_ERROR } from './actionTypes'; 2 | 3 | export function addTodo(text) { 4 | return { type: ADD_TODO, text }; 5 | } 6 | 7 | export function addTodoAsync(text) { 8 | return dispatch => new Promise(resolve => setImmediate(() => { 9 | dispatch(addTodo(text)); 10 | resolve(); 11 | })); 12 | } 13 | 14 | export function addTodoIfEmpty(text) { 15 | return (dispatch, getState) => { 16 | if (!getState().length) { 17 | dispatch(addTodo(text)); 18 | } 19 | }; 20 | } 21 | 22 | export function dispatchInMiddle(boundDispatchFn) { 23 | return { 24 | type: DISPATCH_IN_MIDDLE, 25 | boundDispatchFn, 26 | }; 27 | } 28 | 29 | export function throwError() { 30 | return { 31 | type: THROW_ERROR, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /test/helpers/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const ADD_TODO = 'ADD_TODO'; 2 | export const DISPATCH_IN_MIDDLE = 'DISPATCH_IN_MIDDLE'; 3 | export const THROW_ERROR = 'THROW_ERROR'; 4 | -------------------------------------------------------------------------------- /test/helpers/document.js: -------------------------------------------------------------------------------- 1 | if (typeof document === 'undefined') { 2 | global.document = {}; 3 | } 4 | -------------------------------------------------------------------------------- /test/helpers/middleware.js: -------------------------------------------------------------------------------- 1 | export function thunk({ dispatch, getState }) { 2 | return next => action => 3 | typeof action === 'function' ? 4 | action(dispatch, getState) : 5 | next(action); 6 | } 7 | 8 | export function testMiddleware(suffix) { 9 | return () => next => action => { 10 | action.text = action.text + suffix; 11 | return next(action); 12 | }; 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/helpers/reducers.js: -------------------------------------------------------------------------------- 1 | import { ADD_TODO, DISPATCH_IN_MIDDLE, THROW_ERROR } from './actionTypes'; 2 | 3 | 4 | function id(state = []) { 5 | return state.reduce((result, item) => ( 6 | item.id > result ? item.id : result 7 | ), 0) + 1; 8 | } 9 | 10 | export function todos(state = [], action) { 11 | switch (action.type) { 12 | case ADD_TODO: 13 | return [...state, { 14 | id: id(state), 15 | text: action.text, 16 | }]; 17 | default: 18 | return state; 19 | } 20 | } 21 | 22 | export function todosReverse(state = [], action) { 23 | switch (action.type) { 24 | case ADD_TODO: 25 | return [{ 26 | id: id(state), 27 | text: action.text, 28 | }, ...state]; 29 | default: 30 | return state; 31 | } 32 | } 33 | 34 | export function dispatchInTheMiddleOfReducer(state = [], action) { 35 | switch (action.type) { 36 | case DISPATCH_IN_MIDDLE: 37 | action.boundDispatchFn(); 38 | return state; 39 | default: 40 | return state; 41 | } 42 | } 43 | 44 | export function errorThrowingReducer(state = [], action) { 45 | switch (action.type) { 46 | case THROW_ERROR: 47 | throw new Error(); 48 | default: 49 | return state; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/utils/document.js: -------------------------------------------------------------------------------- 1 | if (typeof document === 'undefined') { 2 | global.document = {}; 3 | } 4 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var webpack = require('webpack'); 4 | 5 | var plugins = [ 6 | new webpack.optimize.OccurenceOrderPlugin(), 7 | new webpack.DefinePlugin({ 8 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 9 | }) 10 | ]; 11 | 12 | if (process.env.NODE_ENV === 'production') { 13 | plugins.push( 14 | new webpack.optimize.UglifyJsPlugin({ 15 | compressor: { 16 | screw_ie8: true, 17 | warnings: false 18 | } 19 | }) 20 | ); 21 | } 22 | 23 | module.exports = { 24 | module: { 25 | loaders: [{ 26 | test: /\.js$/, 27 | loaders: ['babel-loader'], 28 | exclude: /node_modules/ 29 | }] 30 | }, 31 | output: { 32 | library: 'redux-component-state', 33 | libraryTarget: 'umd' 34 | }, 35 | plugins: plugins, 36 | resolve: { 37 | extensions: ['', '.js'] 38 | } 39 | }; 40 | --------------------------------------------------------------------------------