├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── actions.js ├── index.js ├── local.js ├── localReducer.js └── utils.js └── tests ├── helpers ├── configureStore.js ├── sagas.js └── setupEnv.js ├── localReducerSpec.js ├── localSpec.js └── utilsSpec.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "rules": { 5 | "import/no-unresolved": 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | .nyc_output 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # dev-oly folders 2 | tests 3 | npm-debug.log 4 | coverage 5 | .nyc_output 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | branches: 5 | only: 6 | - master 7 | - /^greenkeeper-.*$/ 8 | after_success: 9 | - './node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls' 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.7.1 2 | - Prevent state from being destroying when components with the same keys 3 | enter/exit the view(due to destroying being done in a timeout) 4 | 5 | ## 1.5.0 6 | - Add support for mergeProps ( thanks @farism ) 7 | - Cleanup filterGlobalActionscallback upon component unmount 8 | - Linting 9 | 10 | ## 1.4.1 11 | - Fixed a bug preventing composition of `local` HOC with react-redux `connect` 12 | - Add `react-redux` as a peer dependency 13 | 14 | ## 1.4 15 | - Hoist the wrapped component contextTypes into the `local` HOC( thanks @kuon ) 16 | - Hoist all non react statics into the `local` HOC 17 | - Provide a display name for components generated by `local` 18 | 19 | ## 1.3 20 | - Stores are now shareable among all components that have the same `key` 21 | - Pass in the component context to all of callback style functions defined on `local`. 22 | Configuration now can be defined as 23 | ```js 24 | local({ 25 | key: (props, context) => ..., 26 | createStore: (props, existingState, context) => ... 27 | persist: (props, context) => ... 28 | }) 29 | ``` 30 | - Add the `mergeReducers` utility in 'redux-fractal/utils' 31 | - Renamed 'triggerComponentKey' and 'currentComponentKey' set on actions meta by 32 | `redux-fractal` to reduxFractalTriggerComponent and reduxFractalCurrentComponent to prevent 33 | name collisions with user code. 34 | - Documentation improvements 35 | 36 | ## 1.2 37 | 38 | - Made the `persist` flag configurable by being able to define it also as a function of props 39 | 40 | ## 1.1 41 | 42 | - Add ability to persist state when component unmounts by configuring `local` HOC with a `persist` boolean flag 43 | 44 | ## 1.0.0 45 | 46 | - Initial release 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Cazaciuc Gabriel 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-fractal 2 | ![Travis CI](https://travis-ci.org/gcazaciuc/redux-fractal.svg?branch=master) 3 | [![Coverage Status](https://coveralls.io/repos/github/gcazaciuc/redux-fractal/badge.svg)](https://coveralls.io/github/gcazaciuc/redux-fractal) 4 | 5 | Local component state & actions in Redux. 6 | 7 | Provides the means to hold up local component state in Redux state, to dispatch locally scoped actions and to react to global ones. 8 | 9 | What Redux fractal offers is a Redux private store for each component with the notable difference that the component state is actually held up in your 10 | app's state atom, so all global and components ui state live together. 11 | 12 | The unique and powerful approach consists in the fact that it allows you to 13 | use the built-in Redux createStore, combineReducers and others for defining the shape and managing the UI ,state with great benefits: 14 | - All state ( either application state or UI state) is help up in your app single state atom 15 | - Component state updates are managed via reducers just like your global app state which means predictability and easy testability 16 | - Reducers used for local state aren't aware that they are used for managing state for a component instance. As such they can be easily re-used across components or even used for managing global state. 17 | - You can have per component middleware. This opens up interesting possibilities like locally scoped sagas(eg redux-saga), intercepting and handling of all actions generated by a component before they reach the component's UI store. 18 | - By default a component intercepts in reducer only actions generated by itself but it;s easy to enable intercepting global actions or actions generated by other components. 19 | 20 | It's easy to get started using redux-fractal 21 | ## Installation 22 | 23 | ```console 24 | $ npm install --save redux-fractal 25 | ``` 26 | ## Usage 27 | ### Importing the local reducer into global store 28 | Add the local reducer to the redux store under the 29 | 'local' reducer key; 30 | ```js 31 | import { localReducer } from 'redux-fractal'; 32 | const store = createStore(combineReducers({ 33 | local: localReducer, 34 | myotherReducer: myotherReducer 35 | })) 36 | ``` 37 | ### Adding the `local` HOC to components maintaining UI state 38 | Decorate the components that hold ui state( transient state, scoped to that very specific component ) with the 'local' higher order component and provide a mandatory, globally unique key for your component and a `createStore` method. 39 | 40 | The key can be generated based on props or a static string but it must be stable between re-renders. Basically it should follow exactly 41 | the same rules as the React component 'key'. In general it should be unique among components, unless you 42 | want multiple components to use the same store. 43 | ```js 44 | import { local } from 'redux-fractal'; 45 | import { createStore } from 'redux'; 46 | 47 | const CompToRender = local({ 48 | key: 'myDumbComp', 49 | createStore: (props) => { 50 | return createStore(rootReducer, { filter: true, sort: props.sortOrder }) 51 | } 52 | })(Table); 53 | ``` 54 | ### Defining root reducer for the private store 55 | Define a root reducer that will intercept own dispatched functions( by default only actions dispatched from the wrapped component, but the 'local' higher order component can be easily configured to intercept actions from global Redux instance or generated by other components): 56 | ```js 57 | const rootReducer = (state = { filter: null, sort: null, trigger: '', current: '' }, action) => { 58 | switch(action.type) { 59 | case 'SET_FILTER': 60 | return Object.assign({}, state, { filter: action.payload }); 61 | case 'SET_SORT': 62 | return Object.assign({}, state, 63 | { sort: action.payload }); 64 | case 'GLOBAL_ACTION': 65 | return Object.assign({}, state, { filter: 'globalFilter' }); 66 | case 'RESET_DEFAULT': 67 | return Object.assign({}, state, { sort: state.sort+'_globalSort' }); 68 | default: 69 | return state; 70 | } 71 | }; 72 | ``` 73 | Note that the reducer is like any other ordinary reducer used in a Redux app. The difference is that it manages and controls the state transitions for a certain component state. 74 | 75 | In fact, you can use whatever method of combining reducers you use for your app with no exceptions, also for the individual components: 76 | ```js 77 | import { combineReducers, createStore } from 'redux'; 78 | const rootReducer = combineReducers({ 79 | filter: filterReducer, 80 | sort: sortReducer 81 | }); 82 | local({ 83 | key: (props) => props.tableID, 84 | createStore: (props) => { 85 | return createStore(rootReducer, componentInitialState); 86 | } 87 | }) 88 | ``` 89 | ## Accessing local state and dispatching local actions 90 | The well know `mapStateToProps`, `mapDispatchToProps`, and `mergeProps` familiar from react-redux `connect` are available having the very same signatures. 91 | In fact , internally, redux-fractal uses the connect function from 'react-redux' to connect the component to it's private store. 92 | 93 | The difference is, that you get only the component's state in `mapStateToProps` as opposed to the entire app state and the `dispatch` 94 | function in `mapDispatchToProps` dispatches an action tagged with the component key as specified in the HOC config. `mergeProps`, on the other hand, is no different. 95 | `mapStateToProps`, `mapDispatchToProps`, and `mergeProps` are completely optional, define them if you need them. 96 | ### Mapping component state to props 97 | Beware that the components wrapped in 'local' HOC do not update when global state changes but only when their own state changes. These components are effectively connected to their own private store. 98 | Of course, you can also connect them to the global store using standard 'connect' function. 99 | ```js 100 | local({ 101 | key: 'mycomp', 102 | createStore: (props) => createStore(rootReducer, initialState), 103 | mapStateToProps: (componentState, ownProps) => { 104 | // Get component state from it's private store and 105 | // component own props. 106 | // You must return an object containing the keys that will become props to the component just like in react redux 'connect' 107 | return { 108 | filter: getFilter(componentState) 109 | } 110 | }, 111 | 112 | }) 113 | ``` 114 | By default, if no `mapStateToProps` is defined, then all keys from the component's state become 115 | individual props in the wrapped component: 116 | ```js 117 | local({ 118 | key: 'mycomp', 119 | createStore: (props) => createStore(rootReducer, { filter: 'all', sort: 'asc' }), 120 | mapStateToProps: (componentState, ownProps) => { 121 | // Get component state from it's private store and 122 | // component own props. 123 | // You must return an object containing the keys that will become props to the component just like in react redux 'connect' 124 | return { 125 | filter: getFilter(componentState) 126 | } 127 | }, 128 | 129 | })(Table) 130 | ``` 131 | In the Table you now have access to the 'filter' state via props.filter. 132 | ### Dispatching local actions 133 | Local actions can be dispatched by defining a 'mapDispatchToProps' function. 134 | Note that local dispatches can be caught by the component's own reducer AND by global, application wide reducers. 135 | ```js 136 | import { updateSearchTerm } from '' 137 | local({ 138 | key: 'mycomp', 139 | createStore: (props) => createStore(rootReducer, initialState), 140 | mapDispatchToProps: (dispatch) => { 141 | // ALL actions dispatched via 'dispatch' function above have the component key tagged to the action. 142 | // You can see that by inspecting action.meta.reduxFractalTriggerComponent in redux dev tools. 143 | // All local actions are dispatched also on the global store 144 | // You must return an object containing the keys that will become props to the component just like in react redux 'connect' 145 | return { 146 | onFilter: (term) => dispatch(updateSearchTerm(term)) 147 | } 148 | }, 149 | 150 | }) 151 | ``` 152 | 153 | These actions can be caught in any global reducer and by default only in the originating component reducer. 154 | One can inspect the originating component by looking at `action.meta.reduxFractalTriggerComponent` to get the component's key that dispatched the action. 155 | 156 | ```js 157 | import { combineReducers, createStore } from 'redux'; 158 | const rootReducer = combineReducers({ 159 | filter: filterReducer, 160 | sort: sortReducer 161 | }); 162 | local({ 163 | id: "mygreattable", 164 | createStore: (props) => { 165 | return createStore(rootReducer, componentInitialState); 166 | }, 167 | mapStateToProps: (componentState, ownProps) => ({ 168 | filter: getTableFilter(componentState) 169 | }), 170 | mapDispatchToProps: (localDispatch) =>({ 171 | onFilter: (term) => localDispatch(updateSearchTerm(term)) 172 | }) 173 | }) 174 | ``` 175 | ### Reacting to globally dispatched actions or actions dispatched from other components 176 | By default your component will not react to when other components dispatch 177 | local actions or when something is being dispatched in the global store. 178 | You can change that using `filterGlobalActions` which must return either true if the action 179 | can be forwarded to the component's store or false otherwise. 180 | Locally dispatched actions are ALWAYS forwarded to the corresponding component reducer. 181 | You need to define only if you care in updating the component state based on actions happening globally or in other 182 | components. 183 | 184 | ```js 185 | local({ 186 | id: (props) => props.itemID, 187 | creat 188 | filterGlobalActions: (action) => { 189 | // Any logic to determine if the actions should be forwarded 190 | // to the component's reducer. By default none is except those 191 | // originated by component itself 192 | const allowedActions = ['RESET_FILTERS', 'CLEAR_SORTING']; 193 | return allowedActions.indexOf(action.type) !== -1; 194 | } 195 | }) 196 | ``` 197 | Now any `RESET_FILTERS` or `CLEAR_SORTING` global actions or originated by other components will be allowed. 198 | You have lots of flexibility with this method to react when a component updates it's UI state. 199 | Crazy example: when the sorting from one component changes all dropdowns from another component should close: 200 | ```js 201 | local({ 202 | id: 'dropdownsContainer', 203 | createStore: (props) => { 204 | return createStore((state = {isClosed: false}, action) => { 205 | switch(action.type) { 206 | case SET_SORT: 207 | return Object.assign({}, state, { isClosed: true }); 208 | break; 209 | default: 210 | return state; 211 | } 212 | }); 213 | }, 214 | filterGlobalActions: (action) => { 215 | // This component is interested in updating it's state 216 | // when things happen in the 'mygreattable' component, in this 217 | // case when sorting changes 218 | const allowedActions = ['SET_SORT']; 219 | return allowedActions.indexOf(action.type) !== -1 && actions.meta.reduxFractalTriggerComponent === 'mygreattable'; 220 | } 221 | }) 222 | ``` 223 | ## Merging local state, local dispatch, and own props 224 | Just like in `connect`, you have the opportunity to transform the final props that are passed into your component using `mergeProps`: 225 | ``` 226 | local({ 227 | key: 'mycomp', 228 | createStore: (props) => createStore(rootReducer, initialState), 229 | mapStateToProps: (componentState, ownProps) => { 230 | ... 231 | }, 232 | mapDispatchToProps: (localDispatch) =>({ 233 | onFilter: (term) => localDispatch(updateSearchTerm(term)) 234 | }), 235 | mergeProps: (state, localDispatch, ownProps) =>({ 236 | { 237 | ...ownProps, 238 | ...state, 239 | ...localDispatch 240 | } 241 | }) 242 | }) 243 | ``` 244 | ## Local middleware 245 | Since Redux-fractal relies on redux for manging the component state and offers a private store for the component 246 | you can define middleware for the private component store using the exact same approach as you do when setting up 247 | the application's store. 248 | This opens up some interesting possibilities: 249 | - Use locally scoped sagas that get destroyed on component unmount(eg using [Redux saga](https://github.com/yelouafi/redux-saga) ). These sagas will have access only to the component's private store( so all yield select() would actually return the component's state) and also be able to react to all local actions and actions allowed by `filterGlobalActions`. 250 | - Apply per component throttling/debouncing of actions using middleware's such as redux-batch-subscribers 251 | - Transform the actions originating in components of a certain type 252 | - Track at all times the component that initiated for eg a server request, validations etc by using locally 253 | scoped redux-thunk middleware and dispatching thunks( in which case the 'dispatch' and `getState` functions will be the ones from the component local store) 254 | - Re-use middleware that you use on the global store on the component's private store 255 | 256 | ### Local Redux saga example 257 | First install redux-saga as describe here [Redux saga](https://github.com/yelouafi/redux-saga). 258 | Define a saga somewhere( eg in sagas.js): 259 | ```js 260 | function* fetchUser(action) { 261 | const compState = yield select(); 262 | try { 263 | const user = yield call(Api.fetchUser, action.payload.userId); 264 | yield put({type: "USER_FETCH_SUCCEEDED", user: user}); 265 | } catch (e) { 266 | yield put({type: "USER_FETCH_FAILED", message: e.message}); 267 | } 268 | } 269 | export default function* mySaga() { 270 | yield* takeLatest("USER_FETCH_REQUESTED", fetchUser); 271 | } 272 | ``` 273 | Then wrap a component in the 'local' HOC: 274 | ```js 275 | import { createStore, applyMiddleware } from 'redux' 276 | import createSagaMiddleware from 'redux-saga' 277 | 278 | import reducer from './reducers' 279 | import mySaga from './sagas' 280 | const componentRootReducer = (state = { user: {} }, action) => { 281 | switch(action.type) { 282 | case USER_FETCH_SUCCEEDED: 283 | return Object.assign({}, state, { user: action.payload }); 284 | default: 285 | return state; 286 | } 287 | }; 288 | local({ 289 | key: 'formContainer', 290 | createStore: (props) => { 291 | // create the saga middleware 292 | const sagaMiddleware = createSagaMiddleware(); 293 | const store = createStore( 294 | componentRootReducer, 295 | applyMiddleware(sagaMiddleware) 296 | ); 297 | sagaMiddleware.run(mySaga) 298 | return { store: store, cleanup: () => sagaMiddleware.cancel() }; 299 | }, 300 | mapDispatchToProps: (dispatch) => { 301 | onFetchUser: (userId) => dispatch({type: "USER_FETCH_REQUESTED", payload: userId }) 302 | } 303 | }) 304 | ``` 305 | All of the `put()` effects dispatch a local action. 306 | All of the `select()` effects return parts of the component's state. 307 | All of the `take()` effects react to the very same action's that local reducers are allowed to react: 308 | - Locally dispatched actions 309 | - Global or other component actions allowed by `filterGlobalActions` function. 310 | 311 | It's important to note that by default component stores do not contain middleware, just as 312 | the global Redux store doesn't contain it by default and middleware needs to be added to it. 313 | This means for example that implictly you can only dispatch plain objects. To dispatch functions, promises etc 314 | configure the private component state with the needed middleware( eg redux-thunk etc ) 315 | ## Recipes 316 | ### I need access to global application state in mapStateToProps of the `local()` component 317 | 318 | Access to only the component's internal state in `mapStateToProps` is a conscious design decision. 319 | If you need data from the global state and need to update the component when that data changes you can 320 | wrap the component returned by `local` in `connect()`. 321 | That way you will be able to pass data from the global store into the component returned by `local` via props. 322 | ```js 323 | import { connect } from 'react-redux'; 324 | import { local } from 'redux-fractal'; 325 | import { createStore, compose } from 'redux'; 326 | const wrapper = compose( 327 | connect((state) => ({ 328 | userSettings: state.UserSettingsReducer.settings 329 | })), 330 | local({ 331 | key: 'comp', 332 | createStore: (props) => { 333 | const filterVal = props.userSettings.defaultFilter; 334 | return createStore(componentRootReducer, { filter: filterVal }); 335 | } 336 | }); 337 | export default wrapper(MyComponent); 338 | ``` 339 | ### I need to keep the local state even if the component gets unmounted and restore it when it gets mounted again 340 | In that case you can use the `persist` flag on the `local` HOC. When the flag is set to `true` 341 | you will get the existing component state, when the component re-mounts, as the second parameter 342 | to the `createStore` function. 343 | ```js 344 | import { local } from 'redux-fractal'; 345 | import { createStore } from 'redux'; 346 | const wrapper = local({ 347 | key: 'comp', 348 | createStore: (props, existingState) => { 349 | const filterVal = 'search'; 350 | return createStore(componentRootReducer, existingState || { filter: filterVal }); 351 | }, 352 | persist: true 353 | }); 354 | export default wrapper(MyComponent); 355 | ``` 356 | If there are multiple instance of MyComponent, using `persist` as shown above will persist the state of ALL instances. 357 | You can have fine grained control over which component instances get persisted and which do not 358 | by defining `persist` as a function receiving the component props. 359 | ```js 360 | const HOC = local({ 361 | key: (props) => props.itemId, 362 | createStore: (props, existingState) => { 363 | return createStore( 364 | rootReducer, 365 | existingState || { filter: true } 366 | ); 367 | }, 368 | // Any logic depending on props here to decide if the component state should be persisted 369 | persist: (props) => props.keepState 370 | }); 371 | const ConnectedItem = HOC(Item); 372 | 373 | const App = (props) => { 374 | return ( 375 |
376 | 377 | 378 |
379 | ); 380 | } 381 | ``` 382 | In the above example only the state of component with itemId 'a' will be kept in the global 383 | state after the component unmounts. 384 | 385 | ### I need to read a component's state somewhere else: eg in thunk action creators, sagas, in `mapStateToProps` 386 | All the local components state is available at `state.local[key]` where `key` is the key for the component 387 | as return by the `key` property of the `local` HOC. 388 | 389 | ### Sharing the same stores across multiple components 390 | 391 | Starting with version 1.3 it's possible to share the same store across multiple components. All of the components 392 | having the same `key` will have access to the store's state and be able to dispatch actions on it. 393 | To do this in a sane manner there are a few rules to be followed: 394 | - Components having the same `key` value defined for `local` HOC use the same store 395 | - The component sharing the store state must be a parent of the components consuming it 396 | - Child components accessing the shared store should NOT define a `createStore` method. Only the parent component should do this. 397 | - The store lifetime is controlled by the parent defining it. 398 | 399 | It sounds more complicated than it actually is. Let's see an example: 400 | 401 | ```js 402 | // in containers/ParentComp.js 403 | const ParentComp = local({ 404 | key: 'parentKey', 405 | createStore: (props) => createStore(rootReducer, {sort: 1}) 406 | }); 407 | ``` 408 | 409 | ```js 410 | // in containers/ChildComp.js 411 | const ChildComp = local({ 412 | key: 'parentKey', 413 | mapDispatchToProps: (dispatch) => { 414 | onSort: (val) => dispatch(onSort(val)) // I'm dispatching actions on ParentComp store 415 | }, 416 | mapStateToProps: (state, ownProps) => { 417 | // I got the whole ParentComp state in here. Take the state slices you need and inject 418 | // them in the child component 419 | } 420 | }); 421 | ``` 422 | 423 | ### I would like to use React `context` on the components returned by `local` 424 | 425 | There is only 1 thing to be aware: `local` returns a component that already has 426 | `contextTypes` defined in order to be able to access the global Redux store. 427 | As such take care to extend the `contextTypes` and not over-write them. 428 | 429 | Also note that `key`, `createStore` and `persist` receive the component context as 430 | the last parameter so you can make use of everything on the `context` besides props to 431 | create a component's key, store state or control it's persistence settings. 432 | 433 | ```js 434 | // in containers/ParentComp.js 435 | const WrappedComp = local({ 436 | key: (props, context) => context.parentKey, 437 | createStore: (props, existingState, context) => ... 438 | persist: (props, context) => .... 439 | })(MyComp); 440 | WrappedComp.contextTypes = Object.assign({}, WrappedComp.contextTypes, { 441 | parentKey: React.PropTypes.string 442 | }); 443 | ``` 444 | 445 | ### I need to do some store cleanup(eg cancel middleware etc) upon component unmount 446 | 447 | `createStore` can return either a Redux store object or an object having 2 keys: 448 | 449 | ```js 450 | 451 | createStore: (props, existingState) => ({ 452 | store: createStore( .... ), // this is the result of Redux createStore 453 | cleanup: () => .... // cleanup is optional, you don't have to return it 454 | }) 455 | ``` 456 | When the component unmounts, if a cleanup method has been defined it will be automatically invoked. 457 | 458 | ### I need to manually cleanup the state of components created with `persist: true`( a single one or all of them) 459 | 460 | In some situations you might want to blow up a component state manually: 461 | - The component is unmounted and `persist: true` was set for that component 462 | - You navigate away from the page and want to cleanup the state of all components having `persist: true` 463 | 464 | For these cases there are 2 actions that you can import and dispatch: 465 | ```js 466 | import { destroyComponentState, destroyAllComponentsState } from 'redux-fractal'; 467 | Store.dispatch(destroyComponentState(componentKey)); // Destroy a specific component state 468 | Store.dispatch(destroyAllComponentsState()); // Destroy all components state 469 | ``` 470 | 471 | Please note that if there are still components mounted that listen to the destroyed stores 472 | the components will not update anymore. 473 | 474 | ### I want to re-use reducers in multiple components 475 | 476 | You can use any strategy you want for creating the root reducer of a certain component. 477 | Eg you can use `combineReducers` from Redux or apply other strategies. 478 | 479 | One strategy that we found usefull was the `mergeReducers` and it's shipped as part of redux-fractal. 480 | Let's suppose you have the following reducers: 481 | ```js 482 | const EditableReducer = (state = { editState: false }, action) => { 483 | case EDIT_STARTED: 484 | return ..... // Determine new state somehow 485 | case EDIT_STOPPED: 486 | return .... // Determine new state somehow 487 | } 488 | 489 | const FiltersReducer = (state = { filtersList: [] }, action) => { 490 | case SET_FILTER: 491 | return ..... // Determine new state somehow 492 | case REMOVE_FILTER: 493 | return .... // Determine new state somehow 494 | } 495 | ``` 496 | What you would like is to take these 2 reducers and apply them to any components 497 | that have editable and filter behavior , basically which have the same way 498 | of updating their filters and edit state. 499 | Besides that it would be good if the component would also have it's own, component specific data. 500 | 501 | ```js 502 | import { mergeReducers } from 'redux-fractal/utils'; 503 | const ComponentSpecificReducer = (state = { data: {} }, action) => { 504 | case COMPONENT_ACTION: 505 | return ..... 506 | } 507 | const componentRootReducer = mergeReducers(ComponentSpecificReducer, EditableReducer, FiltersReducer) 508 | ``` 509 | 510 | Some interesting points: 511 | 512 | - The final component state would have the shape { data: {}, filtersList: [], editState: false }. 513 | Of course the initial values can be supplied via `createStore` as usual. 514 | - When an action comes in it will be passed throught all reducers from right to left: FiltersReducer then EditableReducer then ComponentSpecificReducer. 515 | - Each reducer will receive the full component state not only it's piece, but it doesn't need to be aware of that is concerned with only updating 516 | it's piece of data( so each one will receive { data: {}, filtersList: [], editState: false } ) 517 | - The merged reducers must all return objects so that they can be merged together into a final state. 518 | 519 | 520 | 521 | ## TODO (Help wanted) 522 | - Write additional tests 523 | - Verify server side rendering 524 | - Improve and better organize docs, add examples, add a contributing guide 525 | - Development warnings similar to React in dev mode for common mistakes 526 | 527 | Feel free to add anything else I may have missed by opening an issue. 528 | We welcome every contribution! 529 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-fractal", 3 | "version": "1.8.1", 4 | "description": "A local component state management library using Redux", 5 | "main": "dist/index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "npmName": "redux-fractal", 10 | "scripts": { 11 | "build": "rimraf dist && cross-env BABEL_ENV=production && $(npm bin)/babel -d dist src", 12 | "prepublish": "npm run build", 13 | "test-dev": "cross-env BABEL_ENV=development && cross-env NODE_ENV=development && ava --watch --serial --no-cache tests/*Spec.js", 14 | "test-dev-coverage": "cross-env BABEL_ENV=development && nyc ava --serial --watch tests/*Spec.js", 15 | "test": "cross-env BABEL_ENV=development && nyc ava --serial tests/*Spec.js", 16 | "pretty-coverage": "./node_modules/.bin/nyc report --reporter=html" 17 | }, 18 | "ava": { 19 | "require": [ 20 | "babel-core/register", 21 | "./tests/helpers/setupEnv.js" 22 | ], 23 | "babel": "inherit" 24 | }, 25 | "babel": { 26 | "presets": [ 27 | "es2015", 28 | "react" 29 | ], 30 | "plugins": [ 31 | "transform-runtime" 32 | ], 33 | "ignore": [ 34 | "tests/**/*Spec.js" 35 | ], 36 | "env": { 37 | "development": { 38 | "sourceMaps": "inline" 39 | } 40 | } 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/gcazaciuc/redux-fractal.git" 45 | }, 46 | "keywords": [ 47 | "javascript", 48 | "redux", 49 | "react", 50 | "local state", 51 | "fractal architecture", 52 | "component state", 53 | "ui state", 54 | "React state" 55 | ], 56 | "author": "Cazaciuc Gabriel", 57 | "license": "MIT", 58 | "bugs": { 59 | "url": "https://github.com/gcazaciuc/redux-fractal/issues" 60 | }, 61 | "homepage": "https://github.com/gcazaciuc/redux-fractal#readme", 62 | "devDependencies": { 63 | "ava": "^0.16.0", 64 | "babel": "^6.5.2", 65 | "babel-cli": "^6.14.0", 66 | "babel-core": "^6.14.0", 67 | "babel-eslint": "^6.1.2", 68 | "babel-plugin-transform-runtime": "^6.15.0", 69 | "babel-polyfill": "^6.13.0", 70 | "babel-preset-es2015": "^6.14.0", 71 | "babel-preset-react": "^6.11.1", 72 | "babel-register": "^6.14.0", 73 | "coveralls": "^2.11.12", 74 | "cross-env": "^3.0.0", 75 | "enzyme": "^2.4.1", 76 | "eslint": "^3.4.0", 77 | "eslint-config-airbnb": "^10.0.1", 78 | "eslint-plugin-import": "^1.14.0", 79 | "eslint-plugin-jsx-a11y": "^2.2.1", 80 | "eslint-plugin-react": "^6.2.0", 81 | "jsdom": "^9.4.2", 82 | "nyc": "^8.1.0", 83 | "prop-types": "^15.6.0", 84 | "react": "^15.3.1", 85 | "react-addons-test-utils": "^15.3.1", 86 | "react-dom": "^15.3.1", 87 | "react-redux": "^4.4.5", 88 | "redux": "^3.5.2", 89 | "redux-saga": "^0.12.0", 90 | "rimraf": "^2.5.4", 91 | "sinon": "^1.17.6" 92 | }, 93 | "peerDependencies": { 94 | "react": "^0.14.0 || ^15.0.0-0 || ^16.0.0", 95 | "prop-types": "^15.6.0", 96 | "redux": "^2.0.0 || ^3.0.0", 97 | "react-redux": "*" 98 | }, 99 | "dependencies": { 100 | "babel-runtime": "^6.11.6", 101 | "hoist-non-react-statics": "^1.2.0", 102 | "invariant": "^2.2.1" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | export const CREATE_COMPONENT_STATE = '@@ui/CREATE_COMPONENT_STATE'; 2 | export const DESTROY_COMPONENT_STATE = '@@ui/DESTROY_COMPONENT_STATE'; 3 | export const DESTROY_ALL_COMPONENTS_STATE = '@@ui/DESTROY_ALL_COMPONENTS_STATE'; 4 | 5 | export const destroyComponentState = (componentKey) => ({ 6 | type: DESTROY_COMPONENT_STATE, 7 | payload: { persist: false, hasStore: true }, 8 | meta: { reduxFractalTriggerComponent: componentKey }, 9 | }); 10 | export const destroyAllComponentsState = () => ({ 11 | type: DESTROY_ALL_COMPONENTS_STATE, 12 | payload: null, 13 | }); 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import local from './local.js'; 2 | import localReducer from './localReducer.js'; 3 | import { 4 | destroyComponentState, destroyAllComponentsState, 5 | } from './actions.js'; 6 | 7 | export { local }; 8 | export { localReducer }; 9 | export { destroyComponentState, destroyAllComponentsState }; 10 | -------------------------------------------------------------------------------- /src/local.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import invariant from 'invariant'; 4 | import hoistNonReactStatics from 'hoist-non-react-statics'; 5 | import { connect } from 'react-redux'; 6 | import * as UIActions from './actions.js'; 7 | import { createStore } from './localReducer.js'; 8 | 9 | export default (Config) => (Component) => { 10 | const defaultMapStateToProps = (state) => state; 11 | const ConnectComp = connect( 12 | Config.mapStateToProps || defaultMapStateToProps, 13 | Config.mapDispatchToProps, 14 | Config.mergeProps)((props) => { 15 | const newProps = Object.assign({}, props); 16 | delete newProps.store; 17 | // eslint-disable-next-line 18 | return (); 19 | }); 20 | class UI extends React.Component { 21 | constructor(props, context) { 22 | super(props, context); 23 | const compKey = typeof Config.key === 'function' ? 24 | Config.key(props, context) : Config.key; 25 | this.store = null; 26 | invariant(Config.key, 27 | '[redux-fractal] - You must supply a key to the component either as a function or string'); 28 | this.compKey = compKey; 29 | this.unsubscribe = null; 30 | } 31 | componentWillMount() { 32 | const existingState = this.context.store.getState().local[this.compKey]; 33 | const storeResult = createStore( 34 | Config.createStore, this.props, 35 | this.compKey, existingState, this.context); 36 | this.store = storeResult.store; 37 | this.storeCleanup = storeResult.cleanup; 38 | this.context.store.dispatch({ 39 | type: UIActions.CREATE_COMPONENT_STATE, 40 | payload: { config: Config, props: this.props, store: this.store, hasStore: !!Config.createStore }, 41 | meta: { reduxFractalTriggerComponent: this.compKey }, 42 | }); 43 | } 44 | componentWillUnmount() { 45 | const persist = typeof Config.persist === 'function' ? 46 | Config.persist(this.props, this.context) : Config.persist; 47 | setTimeout(() => { 48 | this.context.store.dispatch({ 49 | type: UIActions.DESTROY_COMPONENT_STATE, 50 | payload: { persist, hasStore: !!Config.createStore }, 51 | meta: { reduxFractalTriggerComponent: this.compKey } 52 | }); 53 | if (this.storeCleanup) { 54 | this.storeCleanup(); 55 | } 56 | this.store = null; 57 | }, 0); 58 | } 59 | render() { 60 | if (this.props.store) { 61 | // eslint-disable-next-line 62 | console.warn(`Props named 'store' cannot be passed to redux-fractal 'local' 63 | HOC with key ${this.compKey} since it's a reserved prop`); 64 | } 65 | return ( 66 | this.store && 70 | ); 71 | } 72 | } 73 | 74 | UI.contextTypes = Object.assign({}, Component.contextTypes, { 75 | store: PropTypes.shape({ 76 | subscribe: PropTypes.func.isRequired, 77 | dispatch: PropTypes.func.isRequired, 78 | getState: PropTypes.func.isRequired, 79 | }), 80 | }); 81 | UI.propTypes = Object.assign({}, { 82 | store: PropTypes.shape({ 83 | subscribe: PropTypes.func.isRequired, 84 | dispatch: PropTypes.func.isRequired, 85 | getState: PropTypes.func.isRequired, 86 | }), 87 | }); 88 | const displayName = Component.displayName || Component.name || 'Component'; 89 | UI.displayName = `local(${displayName})`; 90 | return hoistNonReactStatics(UI, Component); 91 | }; 92 | -------------------------------------------------------------------------------- /src/localReducer.js: -------------------------------------------------------------------------------- 1 | import * as UIActions from './actions.js'; 2 | 3 | const stores = {}; 4 | const globalActions = {}; 5 | const refCounter = {}; 6 | const defaultGlobalFilter = () => false; 7 | 8 | const initialiseComponentState = (state, payload, componentKey) => { 9 | const { config, store } = payload; 10 | stores[componentKey] = store; 11 | refCounter[componentKey] = refCounter[componentKey] || 0; 12 | refCounter[componentKey]++; 13 | globalActions[componentKey] = config.filterGlobalActions || defaultGlobalFilter; 14 | const initialState = stores[componentKey].getState(); 15 | const newComponentsState = Object.assign({}, state, { [componentKey]: initialState }); 16 | return newComponentsState; 17 | }; 18 | const destroyComponentState = (state, payload, componentKey) => { 19 | refCounter[componentKey] = refCounter[componentKey] || 0; 20 | if (refCounter[componentKey] > 0) { 21 | refCounter[componentKey]--; 22 | } 23 | if (refCounter[componentKey]) { 24 | return state; 25 | } 26 | const newState = Object.assign({}, state); 27 | delete newState[componentKey]; 28 | delete refCounter[componentKey]; 29 | delete stores[componentKey]; 30 | delete globalActions[componentKey]; 31 | return newState; 32 | }; 33 | const updateSingleComponent = (oldComponentState, action, componentKey) => { 34 | const store = stores[componentKey]; 35 | if (store) { 36 | // eslint-disable-next-line 37 | action.meta = Object.assign({}, action.meta, { reduxFractalCurrentComponent: componentKey }); 38 | store.originalDispatch(action); 39 | return store.getState(); 40 | } 41 | return oldComponentState; 42 | }; 43 | 44 | const updateComponentState = (state, action, componentKey) => { 45 | const newState = Object.keys(state).reduce((stateAcc, k) => { 46 | const shouldUpdate = componentKey === k || 47 | (typeof globalActions[k] === 'function' && globalActions[k](action)); 48 | let updatedState = state[k]; 49 | if (shouldUpdate) { 50 | updatedState = updateSingleComponent(state[k], action, k); 51 | return Object.assign({}, stateAcc, { [k]: updatedState }); 52 | } 53 | return stateAcc; 54 | }, {}); 55 | return Object.assign({}, state, newState); 56 | }; 57 | 58 | export default (state = {}, action) => { 59 | const componentKey = action.meta && action.meta.reduxFractalTriggerComponent; 60 | let nextState = null; 61 | switch (action.type) { 62 | case UIActions.CREATE_COMPONENT_STATE: 63 | return initialiseComponentState( 64 | state, 65 | action.payload, 66 | componentKey); 67 | case UIActions.DESTROY_COMPONENT_STATE: 68 | if (!action.payload.persist && stores[componentKey]) { 69 | return destroyComponentState(state, action.payload, componentKey); 70 | } 71 | return state; 72 | case UIActions.DESTROY_ALL_COMPONENTS_STATE: 73 | nextState = state; 74 | Object.keys(state).forEach((k) => { 75 | nextState = destroyComponentState(nextState, {}, k); 76 | }); 77 | return nextState; 78 | default: 79 | return updateComponentState(state, action, componentKey); 80 | } 81 | }; 82 | 83 | 84 | export const createStore = (createStoreFn, props, componentKey, existingState, context) => { 85 | if (!stores[componentKey]) { 86 | const getWrappedAction = (action) => { 87 | let wrappedAction = action; 88 | if (typeof action === 'object') { 89 | const actionMeta = Object.assign({}, 90 | action.meta, 91 | { reduxFractalTriggerComponent: componentKey }); 92 | wrappedAction = Object.assign({}, action, { meta: actionMeta }); 93 | } 94 | return wrappedAction; 95 | }; 96 | const localDispatch = (action) => { 97 | const wrappedAction = getWrappedAction(action); 98 | return context.store.dispatch(wrappedAction); 99 | }; 100 | const storeResult = createStoreFn(props, existingState, context); 101 | let storeCleanup = () => true; 102 | if (storeResult.store) { 103 | stores[componentKey] = storeResult.store; 104 | } 105 | if (storeResult.cleanup) { 106 | storeCleanup = storeResult.cleanup; 107 | } 108 | if (storeResult.dispatch && storeResult.getState) { 109 | stores[componentKey] = storeResult; 110 | } 111 | stores[componentKey].originalDispatch = stores[componentKey].dispatch; 112 | stores[componentKey].dispatch = localDispatch; 113 | return { store: stores[componentKey], cleanup: storeCleanup }; 114 | } 115 | return { store: stores[componentKey] }; 116 | }; 117 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | export const mergeReducers = (...reducers) => { 3 | const reversedReducers = reducers.slice(0).reverse(); 4 | return (state, action) => { 5 | let nextState = state || {}; 6 | reversedReducers.forEach((reducer) => { 7 | if (typeof state === 'undefined') { 8 | // The reducers are being initilized by Redux. Give them a chance to return their 9 | // initial default value and merge all the values together for form the final state 10 | nextState = Object.assign({}, nextState, reducer(undefined, action)); 11 | } else { 12 | nextState = reducer(nextState, action); 13 | } 14 | }); 15 | return nextState; 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /tests/helpers/configureStore.js: -------------------------------------------------------------------------------- 1 | import localReducer from '../../src/localReducer.js'; 2 | import { createStore, combineReducers } from 'redux'; 3 | export function configureStore() { 4 | const store = createStore( 5 | combineReducers({ 6 | local: localReducer, 7 | isVisible: (state = true, action) => { 8 | switch (action.type) { 9 | case 'CLOSE': 10 | return false; 11 | default: 12 | return state; 13 | } 14 | }, 15 | someGlobalState: (state = { isGlobal: true }, action) => { 16 | switch (action.type) { 17 | case 'SET_GLOBAL': 18 | return Object.assign({}, state, { isGlobal: action.payload }); 19 | default: 20 | return state; 21 | } 22 | }, 23 | }) 24 | ); 25 | return store; 26 | } 27 | 28 | export const Store = configureStore(); 29 | -------------------------------------------------------------------------------- /tests/helpers/sagas.js: -------------------------------------------------------------------------------- 1 | import { takeEvery, takeLatest } from 'redux-saga' 2 | import { call, put, select } from 'redux-saga/effects' 3 | const getUser = (userId, sort) => ({ username: 'test', id: userId, sort: sort }); 4 | 5 | function* fetchUser(action) { 6 | const compState = yield select(); 7 | try { 8 | const user = yield call(getUser, action.payload, compState.sort); 9 | yield put({type: "USER_FETCH_SUCCEEDED", payload: user}); 10 | } catch (e) { 11 | yield put({type: "USER_FETCH_FAILED", payload: e.message}); 12 | } 13 | } 14 | 15 | function* mySaga() { 16 | yield* takeEvery("USER_FETCH_REQUESTED", fetchUser); 17 | } 18 | 19 | export default mySaga; 20 | -------------------------------------------------------------------------------- /tests/helpers/setupEnv.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is used to set up the environment that's needed for most 3 | * of the unit tests for the project which includes babel transpilation 4 | * with babel-register, polyfilling, and initializing the DOM with jsdom 5 | */ 6 | require('babel-register') 7 | require('babel-polyfill') 8 | 9 | global.document = require('jsdom').jsdom('') 10 | global.window = document.defaultView 11 | global.navigator = window.navigator 12 | -------------------------------------------------------------------------------- /tests/localReducerSpec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import localReducer from '../src/localReducer.js'; 3 | 4 | test('Should return the correct initial state for the component', t => { 5 | t.deepEqual(localReducer(undefined, {type: undefined, meta: {}}), {}); 6 | }); 7 | -------------------------------------------------------------------------------- /tests/localSpec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { mount } from 'enzyme'; 3 | import { spy, useFakeTimers } from 'sinon'; 4 | import PropTypes from 'prop-types'; 5 | import React from 'react'; 6 | import local from '../src/local.js'; 7 | import { Provider, connect } from 'react-redux'; 8 | import { createStore } from 'redux'; 9 | import mySaga from './helpers/sagas.js'; 10 | import { configureStore } from './helpers/configureStore.js'; 11 | import createSagaMiddleware from 'redux-saga'; 12 | import { applyMiddleware } from 'redux'; 13 | import { destroyAllComponentsState, destroyComponentState } from '../src/index.js'; 14 | 15 | class DummyComp extends React.Component { 16 | constructor(props, context) { 17 | super(props, context); 18 | } 19 | render() { 20 | return (
); 21 | } 22 | } 23 | DummyComp.displayName = 'DummyComp'; 24 | DummyComp.childContextTypes = { 25 | color: PropTypes.string 26 | }; 27 | DummyComp.staticFn = () => 'query'; 28 | DummyComp.staticProp = 'staticProp'; 29 | 30 | class ContextProviderComp extends React.Component { 31 | constructor(props) { 32 | super(props); 33 | } 34 | getChildContext() { 35 | return { sortOrder: "asc", "keepState": true, "id": 'abc' }; 36 | } 37 | render() { 38 | return React.Children.only(this.props.children); 39 | } 40 | } 41 | ContextProviderComp.childContextTypes = { 42 | sortOrder: PropTypes.string, 43 | keepState: PropTypes.bool, 44 | id: PropTypes.string 45 | }; 46 | const rootReducer = (state = { filter: null, sort: null, trigger: '', current: '' }, action) => { 47 | switch(action.type) { 48 | case 'SET_FILTER': 49 | return Object.assign({}, state, { filter: action.payload }); 50 | case 'SET_SORT': 51 | // console.log(action.meta && JSON.stringify(action.meta)); 52 | return Object.assign({}, state, 53 | { sort: action.payload, 54 | trigger: action.meta && action.meta.reduxFractalTriggerComponent, 55 | current: action.meta && action.meta.reduxFractalCurrentComponent 56 | }); 57 | case 'GLOBAL_ACTION': 58 | return Object.assign({}, state, { filter: 'globalFilter' }); 59 | case 'RESET_DEFAULT': 60 | return Object.assign({}, state, { sort: state.sort+'_globalSort' }); 61 | default: 62 | return state; 63 | } 64 | }; 65 | 66 | test('Should return the correct initial state for the component', t => { 67 | const CompToRender = local({ 68 | key: 'myDumbComp', 69 | filterGlobalActions: (action) => { 70 | return false; 71 | }, 72 | createStore: (props) => { 73 | return createStore(rootReducer, { filter: true, sort: props.sortOrder }) 74 | }, 75 | mapDispatchToProps:(dispatch) => ({ 76 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 77 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 78 | }) 79 | })(DummyComp); 80 | const wrapper = mount(); 81 | const filterVal = wrapper.find('DummyComp').props().filter; 82 | const sortVal = wrapper.find('DummyComp').props().sort; 83 | t.deepEqual(filterVal, true); 84 | t.deepEqual(sortVal, 'desc'); 85 | wrapper.unmount(); 86 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 87 | }); 88 | 89 | test(`Should dispatch local actions that update component state. The local actions 90 | should also hit the global app reducers`, t => { 91 | const Store = configureStore(); 92 | const CompToRender = local({ 93 | key: 'myDumbComp', 94 | filterGlobalActions: (action) => { 95 | return false; 96 | }, 97 | createStore: (props) => { 98 | return createStore(rootReducer, { filter: true, sort: props.sortOrder }); 99 | }, 100 | mapDispatchToProps:(dispatch) => ({ 101 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 102 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 103 | }) 104 | })(DummyComp); 105 | const wrapper = mount( 106 | 107 | 108 | ); 109 | let dumbComp = wrapper.find('DummyComp'); 110 | dumbComp.props().onFilter('my term'); 111 | let filterVal = wrapper.find('DummyComp').props().filter; 112 | let sortVal = wrapper.find('DummyComp').props().sort; 113 | t.deepEqual(filterVal, 'my term'); 114 | t.deepEqual(sortVal, 'desc'); 115 | dumbComp = wrapper.find('DummyComp'); 116 | dumbComp.props().onSort('asc'); 117 | filterVal = wrapper.find('DummyComp').props().filter; 118 | sortVal = wrapper.find('DummyComp').props().sort; 119 | t.deepEqual(filterVal, 'my term'); 120 | t.deepEqual(sortVal, 'asc'); 121 | // Check that global state is also updated 122 | t.deepEqual(Store.getState().local, 123 | {"myDumbComp":{"filter":"my term","sort":"asc", trigger:"myDumbComp", current:"myDumbComp"}}); 124 | wrapper.unmount(); 125 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 126 | }); 127 | 128 | test(`Should forward global actions to the component as long as they pass 129 | the global actions filter`, t => { 130 | const Store = configureStore(); 131 | const CompToRender = local({ 132 | key: 'myDumbComp', 133 | filterGlobalActions: (action) => { 134 | return true; 135 | }, 136 | createStore: (props) => { 137 | return createStore(rootReducer, { filter: true, sort: props.sortOrder }) 138 | }, 139 | mapDispatchToProps:(dispatch) => ({ 140 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 141 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 142 | }) 143 | })(DummyComp); 144 | const wrapper = mount(); 145 | let filterVal = wrapper.find('DummyComp').props().filter; 146 | t.deepEqual(filterVal, true); 147 | Store.dispatch({ type: 'GLOBAL_ACTION' }); 148 | t.deepEqual(wrapper.find('DummyComp').props().filter, 'globalFilter'); 149 | wrapper.unmount(); 150 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 151 | }); 152 | 153 | test(`Should NOT forward any global actions if 'filterGlobalActions' function is not defined`, t => { 154 | const Store = configureStore(); 155 | const CompToRender = local({ 156 | key: 'myDumbComp', 157 | createStore: (props) => { 158 | return createStore(rootReducer, { filter: true, sort: props.sortOrder }) 159 | }, 160 | mapDispatchToProps:(dispatch) => ({ 161 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 162 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 163 | }) 164 | })(DummyComp); 165 | const wrapper = mount( 166 | 167 | 168 | 169 | ); 170 | let filterVal = wrapper.find('DummyComp').props().filter; 171 | t.deepEqual(filterVal, true); 172 | Store.dispatch({ type: 'GLOBAL_ACTION' }); 173 | // State remains unchanged as the action is not forwarded 174 | t.deepEqual(wrapper.find('DummyComp').props().filter, true); 175 | wrapper.unmount(); 176 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 177 | }); 178 | 179 | test(`Should not forward other actions besides those the component is tagged 180 | on to the component is filterGlobalActions returns false for the action`, t => { 181 | const Store = configureStore(); 182 | const HOC = local({ 183 | key: (props) => props.id, 184 | filterGlobalActions: (action) => { 185 | const allowedGlobalActions = ['SET_SORT']; 186 | return allowedGlobalActions.indexOf(action.type) !== -1; 187 | }, 188 | createStore: (props) => { 189 | return createStore(rootReducer, { filter: true, sort: props.sortOrder }) 190 | }, 191 | mapDispatchToProps:(dispatch) => ({ 192 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 193 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 194 | }) 195 | }); 196 | const CompToRender = HOC(DummyComp); 197 | const App = (props) => { 198 | return( 199 |
200 | 201 | 202 |
203 | ); 204 | }; 205 | const wrapper = mount( 206 | 207 | 208 | ); 209 | let sortVal = wrapper.find('DummyComp').at(1).props().sort; 210 | t.deepEqual(sortVal, 'desc'); 211 | wrapper.find('DummyComp').at(0).props().onSort('asc'); 212 | // Intercepts all SET_SORT actions no matter where are originated 213 | const props = wrapper.find('DummyComp').at(1).props(); 214 | sortVal = props.sort; 215 | t.deepEqual(sortVal, 'asc'); 216 | t.deepEqual(props.trigger, 'comp1'); 217 | t.deepEqual(props.current, 'comp2'); 218 | wrapper.unmount(); 219 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 220 | }); 221 | 222 | test(`Should be able to render multiple components of the same type 223 | and each should get it's own slice of state and react to it's own 224 | internal actions`, t => { 225 | const Store = configureStore(); 226 | const HOC = local({ 227 | key: (props) => props.id, 228 | filterGlobalActions: (action) => { 229 | const allowedGlobalActions = ['GLOBAL_ACTION', 'RESET_DEFAULT']; 230 | return allowedGlobalActions.indexOf(action.type) !== -1; 231 | }, 232 | createStore: (props) => { 233 | return createStore(rootReducer, { filter: true, sort: props.sortOrder }) 234 | }, 235 | mapDispatchToProps:(dispatch) => ({ 236 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 237 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 238 | }) 239 | }); 240 | const CompToRender = HOC(DummyComp); 241 | const App = (props) => { 242 | return( 243 |
244 | 245 | 246 |
247 | ); 248 | }; 249 | const wrapper = mount( 250 | 251 | 252 | ); 253 | let sortVal1 = wrapper.find('DummyComp').at(0).props().sort; 254 | let sortVal2 = wrapper.find('DummyComp').at(1).props().sort; 255 | t.deepEqual(sortVal1, 'asc'); 256 | t.deepEqual(sortVal2, 'desc'); 257 | // Test local dispatches 258 | wrapper.find('DummyComp').at(0).props().onSort('desc'); 259 | sortVal1 = wrapper.find('DummyComp').at(0).props().sort; 260 | sortVal2 = wrapper.find('DummyComp').at(1).props().sort; 261 | t.deepEqual(sortVal1, 'desc'); 262 | t.deepEqual(sortVal2, 'desc'); 263 | wrapper.find('DummyComp').at(1).props().onSort('asc'); 264 | sortVal1 = wrapper.find('DummyComp').at(0).props().sort; 265 | sortVal2 = wrapper.find('DummyComp').at(1).props().sort; 266 | t.deepEqual(sortVal1, 'desc'); 267 | t.deepEqual(sortVal2, 'asc'); 268 | // Test that both react in their own way to global actions 269 | Store.dispatch({ type: 'RESET_DEFAULT' }); 270 | sortVal1 = wrapper.find('DummyComp').at(0).props().sort; 271 | sortVal2 = wrapper.find('DummyComp').at(1).props().sort; 272 | t.deepEqual(sortVal1, 'desc_globalSort'); 273 | t.deepEqual(sortVal2, 'asc_globalSort'); 274 | // Verify that the subscribers count is updated properly as components unmount 275 | t.deepEqual(Store.getState().local, { 276 | "comp1": { 277 | "filter": true, 278 | "sort": "desc_globalSort", 279 | trigger: 'comp1', 280 | current: 'comp1' 281 | }, 282 | "comp2": { 283 | "filter": true, 284 | "sort": "asc_globalSort", 285 | trigger:'comp2', 286 | current: 'comp2' 287 | } 288 | }); 289 | wrapper.unmount(); 290 | setTimeout(() => { 291 | t.deepEqual( 292 | Store.getState().local, 293 | {} 294 | ); 295 | }, 0); 296 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 297 | }); 298 | 299 | test(`Should accept a mapStateToProps and transform the state using it`, t => { 300 | const Store = configureStore(); 301 | const CompToRender = local({ 302 | key: 'myDumbComp', 303 | filterGlobalActions: (action) => { 304 | return true; 305 | }, 306 | createStore: (props) => { 307 | return createStore( 308 | rootReducer, 309 | { filter: true, sort: props.sortOrder } 310 | ); 311 | }, 312 | mapStateToProps: (state, ownProps) => ({ 313 | filter: state.filter, 314 | computedProp: ownProps.a+ownProps.b 315 | }), 316 | mapDispatchToProps:(dispatch) => ({ 317 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 318 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 319 | }) 320 | })(DummyComp); 321 | const wrapper = mount( 322 | 323 | 324 | ); 325 | let filterVal = wrapper.find('DummyComp').props().filter; 326 | t.deepEqual(filterVal, true); 327 | wrapper.find('DummyComp').props().onFilter('term'); 328 | wrapper.find('DummyComp').props().onSort('asc'); 329 | t.deepEqual(wrapper.find('DummyComp').props().filter, 'term'); 330 | t.deepEqual(wrapper.find('DummyComp').props().sort, undefined); 331 | t.deepEqual(wrapper.find('DummyComp').props().computedProp, 3); 332 | wrapper.unmount(); 333 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 334 | }); 335 | 336 | test(`Should accept a mergeProps transform props using it`, t => { 337 | const Store = configureStore(); 338 | const CompToRender = local({ 339 | key: 'myDumbComp', 340 | filterGlobalActions: (action) => { 341 | return true; 342 | }, 343 | createStore: (props) => { 344 | return createStore( 345 | rootReducer, 346 | { filter: true, sort: props.sortOrder } 347 | ); 348 | }, 349 | mapStateToProps: (state, ownProps) => ({ 350 | filter: state.filter, 351 | computedProp: ownProps.a+ownProps.b 352 | }), 353 | mapDispatchToProps:(dispatch) => ({ 354 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 355 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 356 | }), 357 | mergeProps: (state, dispatch, ownProps) => { 358 | return Object.assign({}, ownProps, state, dispatch, { 359 | computedProp: state.computedProp + 5 360 | }) 361 | } 362 | })(DummyComp); 363 | const wrapper = mount( 364 | 365 | 366 | ); 367 | let filterVal = wrapper.find('DummyComp').props().filter; 368 | t.deepEqual(filterVal, true); 369 | wrapper.find('DummyComp').props().onFilter('term'); 370 | wrapper.find('DummyComp').props().onSort('asc'); 371 | t.deepEqual(wrapper.find('DummyComp').props().filter, 'term'); 372 | t.deepEqual(wrapper.find('DummyComp').props().sort, undefined); 373 | t.deepEqual(wrapper.find('DummyComp').props().computedProp, 8); 374 | wrapper.unmount(); 375 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 376 | }); 377 | 378 | test(`Should keep the state after unmount if persist option is set to true and should 379 | properly reconnect the state when the component is mounted again`, t => { 380 | const Store = configureStore(); 381 | const HOC = local({ 382 | key: 'myDumbComp', 383 | createStore: (props, existingState) => { 384 | return createStore( 385 | rootReducer, 386 | existingState || { filter: true, sort: props.sortOrder } 387 | ); 388 | }, 389 | persist: true 390 | }); 391 | const CompToRender = HOC(DummyComp); 392 | const wrapper = mount( 393 | 394 | 395 | ); 396 | t.deepEqual(Store.getState().local, {'myDumbComp': { filter: true, sort: 'desc' }}); 397 | wrapper.unmount(); 398 | t.deepEqual(Store.getState().local, {'myDumbComp': { filter: true, sort: 'desc' }}); 399 | // Render again the component 400 | const rerenderedWrapper = mount( 401 | 402 | 403 | ); 404 | const sortVal = rerenderedWrapper.find('DummyComp').props().sort; 405 | // The component should be connected to existing state existing of replacing it 406 | t.deepEqual(sortVal, 'desc'); 407 | wrapper.unmount(); 408 | return new Promise((resolve) => setTimeout(() => { 409 | Store.dispatch(destroyAllComponentsState()); 410 | resolve(); 411 | }, 10)); 412 | }); 413 | 414 | test(`Should be able to control whether the component state is persisted or not 415 | upon unmount is a function receiving component props`, t => { 416 | const Store = configureStore(); 417 | const HOC = local({ 418 | key: (props) => props.id, 419 | createStore: (props, existingState) => { 420 | return createStore( 421 | rootReducer, 422 | existingState || { filter: true, sort: props.sortOrder } 423 | ); 424 | }, 425 | persist: (props) => props.keepState 426 | }); 427 | const CompToRender = HOC(DummyComp); 428 | const wrapper = mount( 429 | 430 |
431 | 432 | 433 |
434 |
); 435 | t.deepEqual(Store.getState().local, {'a': { filter: true, sort: 'desc' }, 'b': {filter: true, sort: 'asc'} }); 436 | wrapper.unmount(); 437 | return new Promise((resolve) => setTimeout(() => { 438 | t.deepEqual(Store.getState().local, {'b': {filter: true, sort: 'asc'}}); 439 | Store.dispatch(destroyAllComponentsState()); 440 | resolve(); 441 | }, 10)); 442 | }); 443 | 444 | test(`Should pass the component context as last argument to callback style configs`, t => { 445 | const Store = configureStore(); 446 | const HOC = local({ 447 | key: (props, context) => context.id, 448 | createStore: (props, existingState, context) => { 449 | return createStore( 450 | rootReducer, 451 | existingState || { filter: true, sort: context.sortOrder } 452 | ); 453 | }, 454 | persist: (props, context) => context.keepState 455 | }); 456 | const CompToRender = HOC(DummyComp); 457 | CompToRender.contextTypes = Object.assign({}, CompToRender.contextTypes, { 458 | sortOrder: PropTypes.string, 459 | keepState: PropTypes.bool, 460 | id: PropTypes.string 461 | }); 462 | const wrapper = mount( 463 | 464 | 465 |
466 | 467 | 468 |
469 |
470 |
); 471 | // There is a single comp state generated because the ids of the components are the same 472 | t.deepEqual(Store.getState().local, {'abc': { filter: true, sort: 'asc' } }); 473 | wrapper.unmount(); 474 | // State it's still persisted because 'context' said so 475 | return new Promise((resolve) => setTimeout(() => { 476 | t.deepEqual(Store.getState().local, {'abc': { filter: true, sort: 'asc' } }); 477 | Store.dispatch(destroyAllComponentsState()); 478 | resolve(); 479 | }, 10)); 480 | }); 481 | 482 | test(`Should re-use the same store if 2 components have the same key`, t => { 483 | const Store = configureStore(); 484 | const HOC = local({ 485 | key: (props) => props.id, 486 | createStore: (props, existingState) => { 487 | return createStore( 488 | rootReducer, 489 | existingState || { filter: true, sort: props.sortOrder } 490 | ); 491 | }, 492 | persist: (props) => props.keepState, 493 | mapDispatchToProps: (dispatch) => ({ 494 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 495 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 496 | }) 497 | }); 498 | const otherHOC = local({ 499 | key: (props) => props.id, 500 | persist: (props) => props.keepState, 501 | mapDispatchToProps: (dispatch) => ({ 502 | onFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }), 503 | onSort: (sort) => dispatch({ type: 'SET_SORT', payload: sort }), 504 | }) 505 | }); 506 | const CompToRender = HOC(DummyComp); 507 | const OtherCompToRender = otherHOC(DummyComp); 508 | const wrapper = mount( 509 | 510 |
511 | 512 | 513 |
514 |
); 515 | // There should be a single state for both components 516 | t.deepEqual(Store.getState().local, {'a': { filter: true, sort: 'asc' } }); 517 | wrapper.find('DummyComp').at(0).props().onSort('desc'); 518 | t.deepEqual(Store.getState().local, {'a': { filter: true, sort: 'desc', trigger: 'a', current: 'a' } }); 519 | // Components connected to the same store. Dispatching on the other component affects the same store 520 | wrapper.find('DummyComp').at(1).props().onSort('asc'); 521 | t.deepEqual(Store.getState().local, {'a': { filter: true, sort: 'asc', trigger: 'a', current: 'a' } }); 522 | wrapper.unmount(); 523 | // Should respect the persist the persist setting of the store owner 524 | return new Promise((resolve) => setTimeout(() => { 525 | t.deepEqual(Store.getState().local, {'a': { filter: true, sort: 'asc', trigger: 'a', current: 'a' } }); 526 | Store.dispatch(destroyAllComponentsState()); 527 | t.deepEqual(Store.getState().local, {}); 528 | resolve(); 529 | }, 10)); 530 | }); 531 | 532 | test('Should be able to provide locally scoped middleware', t => { 533 | const Store = configureStore(); 534 | const compReducer = (state = { user: {} }, action) => { 535 | switch(action.type) { 536 | case 'USER_FETCH_SUCCEEDED': 537 | return Object.assign({}, state, { user: action.payload }); 538 | default: 539 | return state; 540 | } 541 | } 542 | const HOC = local({ 543 | key: (props) => props.id, 544 | createStore: (props) => { 545 | const sagaMiddleware = createSagaMiddleware(); 546 | const store = createStore(compReducer, 547 | { user: {}, sort: props.sortOrder }, 548 | applyMiddleware(sagaMiddleware)); 549 | sagaMiddleware.run(mySaga) 550 | return { store: store, cleanup: () => true }; 551 | }, 552 | mapDispatchToProps:(dispatch) => ({ 553 | onFetchUser: (userId) => dispatch({ type: 'USER_FETCH_REQUESTED', payload: userId }), 554 | }) 555 | }); 556 | const CompToRender = HOC(DummyComp); 557 | const App = (props) => { 558 | return( 559 |
560 | 561 | 562 |
563 | ); 564 | }; 565 | const wrapper = mount( 566 | 567 | 568 | ); 569 | wrapper.find('DummyComp').at(1).props().onFetchUser(1); 570 | t.deepEqual(wrapper.find('DummyComp').at(1).props().user, { username: 'test', id: 1, sort: 'desc' }); 571 | t.deepEqual(wrapper.find('DummyComp').at(0).props().user, {}); 572 | wrapper.unmount(); 573 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 574 | }); 575 | 576 | test(`Should blow up a single component state or all of the components state`, t => { 577 | const Store = configureStore(); 578 | const HOC = local({ 579 | key: (props) => props.id, 580 | createStore: (props, existingState) => { 581 | return createStore( 582 | rootReducer, 583 | existingState || { filter: true, sort: props.sortOrder } 584 | ); 585 | }, 586 | persist: (props) => props.keepState 587 | }); 588 | const CompToRender = HOC(DummyComp); 589 | 590 | const wrapper = mount( 591 | 592 |
593 | 594 | 595 | 596 |
597 |
); 598 | // There is a single comp state generated because the ids of the components are the same 599 | t.deepEqual(Store.getState().local, { 600 | 'a': { filter: true, sort: 'none' }, 601 | 'b': { filter: true, sort: 'none' }, 602 | 'c': { filter: true, sort: 'none' } 603 | }); 604 | wrapper.unmount(); 605 | // State it's still persisted because 'context' said so 606 | return new Promise((resolve) => {; 607 | setTimeout(function assert() { 608 | t.deepEqual(Store.getState().local, { 609 | 'a': { filter: true, sort: 'none' }, 610 | 'b': { filter: true, sort: 'none' }, 611 | 'c': { filter: true, sort: 'none' } 612 | }); 613 | Store.dispatch(destroyComponentState('a')); 614 | t.deepEqual(Store.getState().local, { 615 | 'b': { filter: true, sort: 'none' }, 616 | 'c': { filter: true, sort: 'none' } 617 | }); 618 | Store.dispatch(destroyAllComponentsState()); 619 | t.deepEqual(Store.getState().local, {}); 620 | resolve(); 621 | }, 10); 622 | }); 623 | }); 624 | 625 | test(`Should hoist all non-react statics along with wrapped component contextTypes 626 | into the component returned by local`, t => { 627 | const Store = configureStore(); 628 | const HOC = local({ 629 | key: (props, context) => context.id, 630 | createStore: (props, existingState, context) => { 631 | return createStore( 632 | rootReducer, 633 | existingState || { filter: true, sort: context.sortOrder } 634 | ); 635 | }, 636 | persist: (props, context) => context.keepState 637 | }); 638 | const CompToRender = HOC(DummyComp); 639 | t.deepEqual(CompToRender.staticProp, 'staticProp'); 640 | t.deepEqual(typeof CompToRender.staticFn, 'function'); 641 | t.deepEqual(CompToRender.displayName, 'local(DummyComp)'); 642 | return new Promise((resolve) => setTimeout(() => resolve(), 10)); 643 | }); 644 | 645 | test(`Should compose well together with react-redux connect`, t => { 646 | const Store = configureStore(); 647 | const HOC = local({ 648 | key: (props) => props.id, 649 | createStore: (props, existingState) => { 650 | return createStore( 651 | rootReducer, 652 | existingState || { filter: true, sort: props.sortOrder } 653 | ); 654 | }, 655 | persist: (props) => props.keepState 656 | }); 657 | const mapStateToProps = (state) => ({ isGlobal: state.someGlobalState.isGlobal }); 658 | const CompToRender = HOC(connect(mapStateToProps)(DummyComp)); 659 | const wrapper = mount( 660 | 661 |
662 | 663 | 664 | 665 |
666 |
); 667 | const isGlobal = wrapper.find('DummyComp').at(0).props().isGlobal; 668 | t.deepEqual(isGlobal, true); 669 | wrapper.unmount(); 670 | return new Promise((resolve) => setTimeout(() => { 671 | Store.dispatch(destroyAllComponentsState()); 672 | resolve(); 673 | }, 10)); 674 | }); 675 | 676 | test(`Should compose well together with other local HOCs`, t => { 677 | const Store = configureStore(); 678 | const HOC = local({ 679 | key: (props) => props.id, 680 | createStore: (props, existingState) => { 681 | return createStore( 682 | rootReducer, 683 | existingState || { filter: true, sort: props.sortOrder } 684 | ); 685 | }, 686 | persist: (props) => props.keepState 687 | }); 688 | const HOC2 = local({ 689 | key: (props) => props.id2, 690 | createStore: (props, existingState) => { 691 | return createStore( 692 | rootReducer, 693 | existingState || { hoc2Prop: props.isHoc2 } 694 | ); 695 | }, 696 | persist: (props) => props.keepState 697 | }); 698 | const HOC3 = local({ 699 | key: (props) => props.id 700 | }); 701 | const CompToRender = HOC(HOC2(DummyComp)); 702 | const wrapper = mount( 703 | 704 |
705 | 706 | 707 | 708 |
709 |
); 710 | const finalProps = wrapper.find('DummyComp').at(0).props(); 711 | t.deepEqual(finalProps.hoc2Prop, true); 712 | t.deepEqual(finalProps.filter, true); 713 | t.deepEqual(finalProps.sortOrder, 'asc'); 714 | wrapper.unmount(); 715 | return new Promise((resolve) => setTimeout(() => { 716 | Store.dispatch(destroyAllComponentsState()); 717 | resolve(); 718 | }, 10)); 719 | }); 720 | 721 | test.skip(`Should not trigger a double dispatch(dispatch while dispatching) when there 722 | are local HOCS nested and one of the child local unmounts as the result of a parent dispatch`, (t) => { 723 | const Store = configureStore(); 724 | const HOC = local({ 725 | key: (props) => props.id, 726 | createStore: (props, existingState) => { 727 | return createStore( 728 | (state, action) => state, 729 | existingState || { abc: '1' } 730 | ); 731 | } 732 | }); 733 | const HOC2 = local({ 734 | key: (props) => props.id, 735 | filterGlobalActions: (action) => true, 736 | createStore: (props, existingState) => { 737 | return createStore( 738 | (state, action) => { 739 | switch(action.type) { 740 | case 'CLOSE': 741 | return Object.assign({}, state, { isOpen: false }); 742 | default: 743 | return state; 744 | } 745 | }, 746 | existingState || { isOpen: true } 747 | ); 748 | }, 749 | mapDispatchToProps: (dispatch) => ({ 750 | onClose: () => dispatch({ type: 'CLOSE' }) 751 | }) 752 | }); 753 | const SFC = (props) => ( 754 | 755 | ); 756 | SFC.displayName = 'SFC'; 757 | const FirstComp = HOC(SFC); 758 | const SecondComp = HOC2(SFC); 759 | const mapStateToProps = (state) => ({ 760 | isDisplayed: (state.local.b && !state.local.b.isOpen) || true 761 | }); 762 | const mapDispatchToProps = (dispatch) => ({ 763 | onCloseGlobal: () => dispatch({ type: 'CLOSE' }) 764 | }); 765 | 766 | const ParentComp = connect(mapStateToProps, mapDispatchToProps)( (props) => { 767 | return ( 768 |
769 | { } 770 | {props.isDisplayed ? : } 771 |
772 | ) 773 | }); 774 | const wrapper = mount( 775 | 776 | 777 | ); 778 | const secondCompProps = wrapper.find('SFC').at(1).props(); 779 | t.deepEqual(wrapper.find('SFC').length, 2); 780 | return new Promise((resolve, reject) => { 781 | setTimeout(() => { 782 | // secondCompProps.onClose(); 783 | wrapper.find('button').at(1).props().onClick(); 784 | // t.deepEqual(wrapper.find('SFC').length, 1); 785 | setTimeout(() => resolve(), 1000); 786 | }, 100); 787 | }); 788 | }); 789 | -------------------------------------------------------------------------------- /tests/utilsSpec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { mergeReducers } from '../src/utils.js'; 3 | const EditableReducer = (state = { editState: false }, action) => { 4 | switch(action.type) { 5 | case 'EDIT_STARTED': 6 | return Object.assign({}, state, { editState: true }); 7 | case 'EDIT_STOPPED': 8 | return Object.assign({}, state, { editState: false }); 9 | default: 10 | return state; 11 | } 12 | } 13 | 14 | const FiltersReducer = (state = { filtersList: [] }, action) => { 15 | switch(action.type) { 16 | case 'SET_FILTER': 17 | const newFilters = state.filtersList.concat(action.payload); 18 | return Object.assign({}, state, { filtersList: newFilters }); 19 | case 'REMOVE_FILTER': 20 | return state; 21 | default: 22 | return state; 23 | } 24 | } 25 | test('Should return the correct initial state when reducers are merged', t => { 26 | const rootReducer = mergeReducers(EditableReducer, FiltersReducer); 27 | t.deepEqual(rootReducer(undefined, { type: undefined }), { filtersList: [], editState: false }); 28 | }); 29 | 30 | test('Should update the state correctly when reducers are merged', t => { 31 | const rootReducer = mergeReducers(EditableReducer, FiltersReducer); 32 | rootReducer(undefined, { type: undefined }); 33 | t.deepEqual( 34 | rootReducer({ filtersList: [], editState: false }, { type: 'EDIT_STARTED' }), 35 | { filtersList: [], editState: true }); 36 | t.deepEqual( 37 | rootReducer({ filtersList: [], editState: true }, { type: 'SET_FILTER', payload: 'test' }), 38 | { filtersList: ['test'], editState: true }); 39 | }); 40 | --------------------------------------------------------------------------------