├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── CI.yml │ └── Codecov.yml ├── .gitignore ├── .watchmanconfig ├── CHANGELOG.md ├── README.md ├── babel.config.json ├── logo ├── README.md └── images │ ├── Redux_Horizontal_big@2x.png │ ├── Redux_Horizontal_small@2x.png │ ├── Redux_vertical_big@2x.png │ └── Redux_vertical_small@2x.png ├── package-lock.json ├── package.json ├── recipes ├── FETCHING.md ├── INVISIBLE.md ├── POLLING.md └── polling.png └── src ├── completers ├── completeReducer │ ├── README.md │ ├── index.js │ └── test.js ├── completeState │ ├── README.md │ ├── index.js │ └── test.js └── completeTypes │ ├── README.md │ ├── index.js │ └── test.js ├── configuration ├── index.js └── test.js ├── creators ├── createReducer │ ├── README.md │ ├── index.js │ └── test.js ├── createThunkAction │ ├── README.md │ ├── index.js │ └── test.js └── createTypes │ ├── README.md │ ├── index.js │ └── test.js ├── effects ├── onAppend │ ├── README.md │ ├── index.js │ └── test.js ├── onCancel │ ├── README.md │ ├── index.js │ └── test.js ├── onConcatenate │ ├── README.md │ ├── index.js │ └── test.js ├── onCycle │ ├── README.md │ ├── index.js │ └── test.js ├── onDelete │ ├── README.md │ ├── index.js │ └── test.js ├── onDeleteByIndex │ ├── README.md │ ├── index.js │ └── test.js ├── onFailure │ ├── README.md │ ├── index.js │ └── test.js ├── onLoaded │ ├── README.md │ ├── index.js │ └── test.js ├── onLoading │ ├── README.md │ ├── index.js │ └── test.js ├── onReadValue │ ├── README.md │ ├── index.js │ └── test.js ├── onReplace │ ├── README.md │ ├── index.js │ └── test.js ├── onRetry │ ├── README.md │ ├── index.js │ └── test.js ├── onSetValue │ ├── README.md │ ├── index.js │ └── test.js ├── onSpreadValue │ ├── README.md │ ├── index.js │ └── test.js ├── onSuccess │ ├── README.md │ ├── index.js │ └── test.js ├── onToggle │ ├── README.md │ ├── index.js │ └── test.js └── validate.js ├── index.js ├── injections ├── baseThunkAction │ ├── index.js │ └── test.js ├── composeInjections │ └── index.js ├── constants.js ├── emptyThunkAction │ ├── index.js │ └── test.js ├── externalBaseAction │ └── index.js ├── mergeInjections │ ├── index.js │ └── test.js ├── pollingAction │ ├── index.js │ └── test.js ├── singleCallThunkAction │ ├── index.js │ └── test.js ├── withFailure │ ├── README.md │ ├── index.js │ └── test.js ├── withFlowDetermination │ ├── README.md │ ├── index.js │ └── test.js ├── withPostFailure │ ├── README.md │ ├── index.js │ └── test.js ├── withPostFetch │ ├── README.md │ ├── index.js │ └── test.js ├── withPostSuccess │ ├── README.md │ ├── index.js │ └── test.js ├── withPreFetch │ ├── README.md │ ├── index.js │ └── test.js ├── withStatusHandling │ ├── README.md │ ├── index.js │ └── test.js └── withSuccess │ ├── README.md │ └── index.js ├── invisible ├── wrapCombineReducers │ ├── commonReducer.js │ ├── index.js │ └── test.js └── wrapService │ ├── index.js │ └── test.js ├── middlewares ├── README.md └── fetch.js └── utils └── asyncActionsUtils.js /.eslintignore: -------------------------------------------------------------------------------- 1 | src/index.js 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "jest": true 5 | }, 6 | "rules": { 7 | "comma-dangle": ["error", "never"], 8 | "no-console": ["error", {"allow": ["warn", "error"]}], 9 | "arrow-parens": ["off", { "requireForBlockBody": false }], 10 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], 11 | "max-len": "off" 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for npm 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for npm 7 | - package-ecosystem: "npm" 8 | # Look for `package.json` and `lock` files in the `root` directory 9 | directory: "/" 10 | # Check the npm registry for updates every week 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out git repository 10 | uses: actions/checkout@v2 11 | - name: Set up Node.js 12 | uses: actions/setup-node@v2 13 | with: 14 | node-version: '12' 15 | - name: Install dependencies 16 | run: npm ci 17 | - name: Run Lint 18 | run: npm run lint 19 | - name: Run Tests 20 | run: npm run test 21 | -------------------------------------------------------------------------------- /.github/workflows/Codecov.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage Update 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | Codecov: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out git repository 13 | uses: actions/checkout@v2 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: '12' 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Run Tests 21 | run: npm run test -- --coverage 22 | - name: Upload coverage to Codecov 23 | uses: codecov/codecov-action@v1 24 | with: 25 | name: redux-recompose 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .DS_Store 4 | .idea 5 | coverage 6 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/redux-recompose/a9f5196549f4a07ee5c4c25b25b7a738ed21e0b8/.watchmanconfig -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [3.0.0] - 2021-03-15 6 | - Modified some APIs: 7 | `completeState({a:1,b:2}, ['b'])` is now `completeState({description: {a: 1}, ignoredTargets: {b: 2})` 8 | `completeTypes(['LOGIN'], ['AUTH_INIT', 'LOGOUT'])` is now `completeTypes({primaryActions: ['LOGIN'], ignoredActions: ['AUTH_INIT', 'LOGOUT'])` 9 | - Introduced polling actions 10 | - Deleted modal-related completers 11 | - Deleted `onSubscribe` and `onUnsubscribe` effects 12 | - Updated dependencies 13 | - Optimized building configuration 14 | 15 | ## [2.0.0] - 2018-09-14 16 | - Introduced invisible reducer 17 | 18 | ## [1.0.0] - 2018-01-16 19 | - Completers: `completeReducer`, `completeTypes`, `completeState` 20 | - Creators: `createReducer`, `createTypes`, `createThunkAction` 21 | - Effects: `onDelete`, `onDeleteByIndex`, `onFailure`, `onLoaded`, `onLoading`, `onReadValue`, `onSetValue`, `onSuccess` 22 | - Some basic Injections to customize `baseThunkAction`. 23 | 24 | 25 | ## [0.0.1] - 2017-12-14 26 | - Added basic effects: `onLoading`, `onLoaded`, `onSuccess`, `onFailure` 27 | - Added `createTypes` and `createReducer` as creators. 28 | - Added `completeReducer` and `completeState` as completers. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![versión npm](https://img.shields.io/npm/v/redux-recompose.svg?color=68d5f7) 3 | ![Download npm](https://img.shields.io/npm/dw/redux-recompose.svg?color=7551bb) 4 | [![codecov](https://codecov.io/gh/Wolox/redux-recompose/branch/master/graph/badge.svg)](https://codecov.io/gh/Wolox/redux-recompose) 5 | [![supported by](https://img.shields.io/badge/supported%20by-Wolox.💗-blue.svg)](https://www.wolox.com.ar/) 6 | # Redux-recompose 7 | ![Vertical Logo Redux-recompose](./logo/images/Redux_vertical_small@2x.png) 8 | 9 | ## Why another Redux library ? 10 | 11 | `redux-recompose` provide tools to write less reducers/actions code. 12 | 13 | Here is a [blog post](https://medium.com/wolox-driving-innovation/932e746b0198) about it. 14 | 15 | Usually, we are used to write: 16 | 17 | ```js 18 | // actions.js 19 | 20 | function increment(anAmount) { 21 | return { type: 'INCREMENT', payload: anAmount }; 22 | } 23 | 24 | // reducer.js 25 | 26 | function reducer(state = initialState, action) { 27 | switch(action.type) { 28 | case 'INCREMENT': 29 | return { ...state, counter: state.counter + action.payload }; 30 | default: 31 | return state; 32 | } 33 | } 34 | ``` 35 | 36 | With the new concept of _target_ of an action, we could write something like: 37 | 38 | ```js 39 | // actions.js 40 | 41 | // Define an action. It will place the result on state.counter 42 | function increment(anAmount) { 43 | return { type: 'INCREMENT', target: 'counter', payload: anAmount }; 44 | } 45 | 46 | 47 | // reducer.js 48 | // Create a new effect decoupled from the state structure at all. 49 | const onAdd = (state, action) => ({ ...state, [action.target]: state[action.target] + action.payload }); 50 | 51 | // Describe your reducer - without the switch 52 | const reducerDescription = { 53 | 'INCREMENT': onAdd() 54 | } 55 | 56 | // Create it ! 57 | const reducer = createReducer(initialState, reducerDescription); 58 | ``` 59 | 60 | ## Effects 61 | 62 | Effects are functions that describe _how_ the state changes, but are agnostic of _what part_ 63 | of the state is being changed. 64 | 65 | `redux-recompose` provides some effects to ease reducer definitions. These are: 66 | 67 | - [onDelete](./src/effects/onDelete/) 68 | - [onDeleteByIndex](./src/effects/onDeleteByIndex/) 69 | - [onFailure](./src/effects/onFailure/) 70 | - [onLoaded](./src/effects/onLoaded/) 71 | - [onLoading](./src/effects/onLoading/) 72 | - [onReadValue](./src/effects/onReadValue/) 73 | - [onSetValue](./src/effects/onSetValue/) 74 | - [onSuccess](./src/effects/onSuccess/) 75 | - [onAppend](./src/effects/onAppend/) 76 | - [onConcatenate](./src/effects/onConcatenate/) 77 | - [onToggle](./src/effects/onToggle/) 78 | - [onSpreadValue](./src/effects/onSpreadValue/) 79 | - [onCycle](./src/effects/onCycle/) 80 | - [onReplace](./src/effects/onReplace/) 81 | - [onRetry](./src/effects/onRetry/) 82 | - [onCancel](./src/effects/onCancel/) 83 | 84 | New effects are welcome ! Feel free to open an issue or even a PR. 85 | 86 | ## Creators 87 | 88 | There are a few creators that also ease writing Redux reducers and async actions. 89 | 90 | - [createReducer](./src/creators/createReducer/) 91 | - [createTypes](./src/creators/createTypes/) 92 | - [createThunkAction](./src/creators/createThunkAction/) 93 | 94 | Since state handling is decoupled from its state, we could create some more complex async actions, or even map an effect with an action type to create families of actions. 95 | More crazy and useful ideas are welcome too! 96 | 97 | ## Completers 98 | 99 | You could use completers to reduce your code size. Completers are functions that take 100 | partial definitions (i.e. descriptors) and help to construct the whole definition. 101 | 102 | Completers in general looks like this: 103 | 104 | - A pattern is being repeated in an element. 105 | - Identify that pattern and try to apply to every element similar to those who use this pattern, although they apply it or not. 106 | - Add some exceptions for elements who don't use this pattern. 107 | - Compress your code size by applying that pattern to all elements but not for exception cases. 108 | 109 | There are a few completers that can be used: 110 | 111 | - [completeState](./src/completers/completeState/) 112 | - [completeReducer](./src/completers/completeReducer/) 113 | - [completeTypes](./src/completers/completeTypes/) 114 | 115 | ## Injectors 116 | 117 | There's currently documentation for the following: 118 | 119 | - [withFailure](./src/injections/withFailure/) 120 | - [withFlowDetermination](./src/injections/withFlowDetermination/) 121 | - [withPostFailure](./src/injections/withPostFailure/) 122 | - [withPostFetch](./src/injections/withPostFetch/) 123 | - [withPostSuccess](./src/injections/withPostSuccess/) 124 | - [withPreFetch](./src/injections/withPreFetch/) 125 | - [withStatusHandling](./src/injections/withStatusHandling/) 126 | - [withSuccess](./src/injections/withSuccess/) 127 | 128 | ## Middlewares 129 | 130 | Middlewares allow to inject logic between dispatching the action and the actual desired change in the store. Middlewares are particularly helpful when handling asynchronous actions. 131 | 132 | The following are currently available: 133 | 134 | - [fetchMiddleware](./src/middlewares/) 135 | 136 | ## Using with immutable libraries 137 | 138 | The way `redux-recompose` updates the redux state can be configured. The default configuration is 139 | 140 | ```js 141 | (state, newContent) => ({ ...state, ...newContent }) 142 | ``` 143 | 144 | You can use `configureMergeState` to override the way `redux-recompose` handles state merging. This is specially useful when you are using immutable libraries. 145 | For example, if you are using `seamless-immutable` to keep your store immutable, you'll want to use it's [`merge`](https://github.com/rtfeldman/seamless-immutable#merge) function. You can do so with the following configuration: 146 | 147 | ```js 148 | import { configureMergeState } from 'redux-recompose'; 149 | 150 | configureMergeState((state, newContent) => state.merge(newContent)) 151 | ``` 152 | 153 | ## Recipes 154 | 155 | - [Making HTTP requests by dispatching a redux action](./recipes/FETCHING.md) 156 | - [Invisible Reducer](./recipes/INVISIBLE.md) 157 | - [Polling](./recipes/POLLING.md) 158 | 159 | ## Thanks to 160 | 161 | This library was inspired by acdlite/recompose. Let's keep creating tools for ease development. 162 | 163 | ## Contributing 164 | 165 | 1. Fork it 166 | 2. Create your feature branch (`git checkout -b my-new-feature`) 167 | 3. Commit your changes (`git commit -am 'Add some feature'`) 168 | 4. Push to the branch (`git push origin my-new-feature`) 169 | 5. Create new Pull Request 170 | 171 | ## About 172 | 173 | This project was written by [Manuel Battan](https://github.com/mvbattan) and it is maintained by [Wolox](http://www.wolox.com.ar). 174 | 175 | ![Wolox](https://raw.githubusercontent.com/Wolox/press-kit/master/logos/logo_banner.png) 176 | 177 | ## License 178 | 179 | **redux-recompose** is available under the MIT [license](LICENSE). 180 | 181 | Copyright (c) 2017 Manuel Battan 182 | 183 | Permission is hereby granted, free of charge, to any person obtaining a copy 184 | of this software and associated documentation files (the "Software"), to deal 185 | in the Software without restriction, including without limitation the rights 186 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 187 | copies of the Software, and to permit persons to whom the Software is 188 | furnished to do so, subject to the following conditions: 189 | 190 | The above copyright notice and this permission notice shall be included in 191 | all copies or substantial portions of the Software. 192 | 193 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 194 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 195 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 196 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 197 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 198 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 199 | THE SOFTWARE. 200 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /logo/README.md: -------------------------------------------------------------------------------- 1 | # Official logo of Redux-recompose 2 | 3 | Our development team and designers have selected the new official logo of `redux-recompose`. 4 | 5 | ## Vertical logo 6 | 7 | ![Vertical Logo Redux-recompose](./images/Redux_vertical_small@2x.png) 8 | 9 | 10 | ## Horizontal logo 11 | 12 | ![Horizontal Logo Redux-recompose](./images/Redux_Horizontal_small@2x.png) 13 | 14 | Here you will find all the images in *[PNG](./images/)* format. 15 | 16 | 17 | ### Credits 18 | 19 | The Redux-recompose logo was designed by [Jorge Montoya](http://www.zabio.co/). 20 | Thanks to [Manuel Battan](https://github.com/mvbattan), [Mariano Zicavo](https://github.com/marianozicavo), [Ana Ospina](https://github.com/Anisospina) and [the community](https://github.com/Wolox/redux-recompose) for contributing and give a little love to project ♥. 21 | -------------------------------------------------------------------------------- /logo/images/Redux_Horizontal_big@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/redux-recompose/a9f5196549f4a07ee5c4c25b25b7a738ed21e0b8/logo/images/Redux_Horizontal_big@2x.png -------------------------------------------------------------------------------- /logo/images/Redux_Horizontal_small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/redux-recompose/a9f5196549f4a07ee5c4c25b25b7a738ed21e0b8/logo/images/Redux_Horizontal_small@2x.png -------------------------------------------------------------------------------- /logo/images/Redux_vertical_big@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/redux-recompose/a9f5196549f4a07ee5c4c25b25b7a738ed21e0b8/logo/images/Redux_vertical_big@2x.png -------------------------------------------------------------------------------- /logo/images/Redux_vertical_small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/redux-recompose/a9f5196549f4a07ee5c4c25b25b7a738ed21e0b8/logo/images/Redux_vertical_small@2x.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-recompose", 3 | "version": "3.0.0", 4 | "description": "A Redux utility belt for reducers and actions. Inspired by acdlite/recompose.", 5 | "engines": { 6 | "node": ">=6.0.0" 7 | }, 8 | "main": "lib/index.js", 9 | "files": [ 10 | "lib" 11 | ], 12 | "scripts": { 13 | "test": "jest", 14 | "compile": "babel src -d lib --ignore '**/test.js'", 15 | "prepack": "npm run compile", 16 | "lint": "eslint src", 17 | "lint-fix": "eslint src --fix", 18 | "lint-diff": "git diff --name-only --cached --relative | grep \\.js$ | xargs eslint", 19 | "precommit": "npm run lint-diff" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/Wolox/redux-recompose.git" 24 | }, 25 | "keywords": [ 26 | "redux", 27 | "switch" 28 | ], 29 | "author": "Wolox", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/Wolox/redux-recompose/issues" 33 | }, 34 | "homepage": "https://github.com/Wolox/redux-recompose#readme", 35 | "devDependencies": { 36 | "@babel/cli": "^7.12.10", 37 | "@babel/core": "^7.12.10", 38 | "@babel/plugin-transform-runtime": "^7.12.10", 39 | "@babel/preset-env": "^7.12.11", 40 | "eslint": "^7.2.0", 41 | "eslint-config-airbnb-base": "^14.2.1", 42 | "eslint-plugin-import": "^2.22.1", 43 | "husky": "^3.0.2", 44 | "jest": "^26.6.3", 45 | "redux": "^3.7.2", 46 | "redux-mock-store": "^1.5.4", 47 | "seamless-immutable": "^7.1.4" 48 | }, 49 | "dependencies": { 50 | "@babel/runtime": "^7.12.5", 51 | "yup": "^0.32.8" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /recipes/FETCHING.md: -------------------------------------------------------------------------------- 1 | # Making HTTP requests by dispatching a redux action 2 | `redux-recompose` enables you to make HTTP requests just by dispatching a redux action. Not only that, it also updates your state so you can know when you are waiting for the response and wether an error ocurred. 3 | 4 | You can check out [this great medium](https://medium.com/wolox/932e746b0198) post by [mvbattan](https://github.com/mvbattan) in order to understand how it works. 5 | 6 | You can try out an invisible reducer in a [demo](https://codesandbox.io/s/primary-actions-example-hl0b4) as well. We recommend using you own browser's console instead of `codesandbox.ios`'s. 7 | -------------------------------------------------------------------------------- /recipes/INVISIBLE.md: -------------------------------------------------------------------------------- 1 | # Invisible Reducer 2 | 3 | By using an invisible reducer, you can combine your service, actions and reducer 4 | 5 | You can check out [this great medium](https://medium.com/wolox/easy-data-management-from-a-rest-api-using-redux-recompose-v2-7c4dc5323445) post by [mvbattan](https://github.com/mvbattan) in order to understand how it works. 6 | 7 | You can try out an invisible reducer in a [demo](https://codesandbox.io/s/invisible-reducer-example-5ikd4) as well. We recommend using you own browser's console instead of `codesandbox.ios`'s. 8 | -------------------------------------------------------------------------------- /recipes/POLLING.md: -------------------------------------------------------------------------------- 1 | # Basic polling recipe 2 | 3 | Imagine you have to translate a PDF. In order to translate it, you will upload it to a server in order for it translate and create a new URL where you can fetch the new translated PDF. 4 | If file is still being processed, the server will respond with a [202 status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/202). Once the PDF has finished processing, the server will respond with a 200 status code and the response body will include a URL where you can download the new PDF. 5 | 6 | Let's define a service using [`apisauce`](https://github.com/infinitered/apisauce). 7 | 8 | ```js 9 | const getTranslationStatus = (translationID) => api.get('translation_status', { translationID }); 10 | ``` 11 | 12 | First we complete the state: 13 | ```js 14 | const completedState = completeState({ pollingTargets: { currentTranslationStatus: null } }); 15 | ``` 16 | 17 | Then we complete our polling types: 18 | ```js 19 | export const actions = createTypes( 20 | completeTypes({ 21 | pollingActions: ['FETCH_TRANSLATION_STATUS'] 22 | }), 23 | '@@TRANSLATIONS' 24 | ); 25 | 26 | const actionCreators = { 27 | getTranslationStatus: (translationID) => ({ 28 | type: actions.FETCH_TRANSLATION_STATUS, 29 | target: 'currentTranslationStatus', 30 | service: getTranslationStatus, 31 | payload: translationID, 32 | determination: (response, getState) => response.status === 200, // the server responds with 200 when it has finished processing 33 | shouldRetry: response => response.status === 202, // if the server responds with 202, then it is still processing and we should ask again in a while 34 | timeout: 10000, // we want to wait 10 seconds before retrying each time 35 | }) 36 | }; 37 | ``` 38 | 39 | We need to complete the reducer as well: 40 | ```js 41 | const reducerDescription = { 42 | pollingActions: [actions.FETCH_PAYMENT_DATA] 43 | }; 44 | const reducer = createReducer(INITIAL_STATE, completeReducer(reducerDescription)); 45 | ``` 46 | 47 | Now we just dispatch the action to start polling: 48 | ```js 49 | dispatch(actionCreators.getTranslationStatus(myTranslationID)); 50 | ``` 51 | 52 | Once you dispatch the polling action, your state will begin changing like this: 53 | 54 | - `currentTranslationStatus`: will have the status once the polling ends successfully. 55 | - `currentTranslationStatusError`: will have the error once the polling ends unsuccessfully. 56 | - `currentTranslationStatusLoading`: will be set to `true` when a request has been made but the response hasn't come yet. 57 | - `currentTranslationStatusIsRetrying`: will be `true` if, after the first request, `shouldRetry` determined the polling must continue. This flag will be set back to `false` once the polling ends. 58 | - `currentTranslationStatusRetryCount`: The amount of times a request has been sent (including the first one). 59 | - `targetTimeoutID`: This timeout ID is used to cancel the polling prematurely. 60 | 61 | If the server responds with a `202`, you'll see `currentTranslationStatusRetryCount` increase and `currentTranslationStatusLoading` toggle back and forth as the subsequent requests are fired. 62 | 63 | If at any time the server responds with a `200`, `currentTranslationStatus` will hold the server response and `currentTranslationStatusIsRetrying` will be set to `false`. This marks the polling as being over. 64 | 65 | If at any time the server responds with any other HTTP status, `currentTranslationStatusError` will hold the server response and `currentTranslationStatusIsRetrying` will be set to `false`. This also marks the polling as being over. 66 | 67 | Here's a diagram to help you further understand how the polling works 68 | ![polling flow diagram](./polling.png "polling flow diagram") 69 | 70 | ## Basic polling recipe with retries limit 71 | 72 | You can add an easy limit to the number of retries with: 73 | ```js 74 | const actionCreators = { 75 | getTranslationStatus: (translationID) => ({ 76 | type: actions.FETCH_TRANSLATION_STATUS, 77 | target: 'currentTranslationStatus', 78 | service: getTranslationStatus, 79 | payload: translationID, 80 | determination: (response, getState) => response.status === 200, 81 | shouldRetry: (response, getState) => response.status === 202 && getState().currentTranslationStatusRetryCount < 10, // only try 10 times 82 | }) 83 | }; 84 | ``` 85 | After the 10th request, failure path of the diagram will be followed. 86 | 87 | ## Cancelling polling 88 | You can cancel the polling at any time with: 89 | ```js 90 | dispatch({type: actions.FETCH_PAYMENT_DATA_CANCEL, target: 'currentTranslationStatus'}); 91 | ``` 92 | 93 | ## Demo 94 | You can try out a [demo](https://codesandbox.io/s/89eic) in order to improve your understanding of polling actions. We recommend using you own browser's console instead of `codesandbox.ios`'s. 95 | -------------------------------------------------------------------------------- /recipes/polling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/redux-recompose/a9f5196549f4a07ee5c4c25b25b7a738ed21e0b8/recipes/polling.png -------------------------------------------------------------------------------- /src/completers/completeReducer/README.md: -------------------------------------------------------------------------------- 1 | ## completeReducer - Completer 2 | 3 | This completer can extend a reducer, helping to reduce its code size. 4 | It receives an object with different string arrays depending on how you want the resulting reducer to handle each action: 5 | 6 | * `primaryActions`: Handles the `_SUCEESS` and `_FAILURE` actions with the `onSuccess` and `onFailure` effects. 7 | * `pollingActions`: Handles the `_RETRY`, `_CANCEL`, `_SUCEESS` and `_FAILURE` actions with the `onRetry`, `onCancel`, `onSuccess` and `onFailure` effects. 8 | * `override`: Overrides any effect this reducer has added. You can use a usual reducer here. 9 | 10 | Example: 11 | ```js 12 | const actions = createTypes(completeTypes({ 13 | primaryActions: ['PRIMARY_ACTION'], 14 | pollingActions: ['POLLING_ACTION'], 15 | ignoredActions: ['IGNORED_ACTION'], 16 | customCompleters: [{actions: ['CUSTOM_ACTION'], completer: action => `${action}_COMPLETED`}] 17 | }), '@@NAMESPACE'); 18 | const reducerDescription = { 19 | primaryActions: [actions.PRIMARY_ACTION], 20 | pollingActions: [actions.POLLING_ACTION], 21 | override: { 22 | [actions.IGNORED_ACTION]: (state, action) => ({ ...state, someTarget: action.payload }), 23 | [actions.CUSTOM_ACTION_COMPLETED]: (state, action) => ({ ...state, someOtherTarget: action.payload }), 24 | [actions.PRIMARY_ACTION_FAILURE]: (state, action) => ({ ...state, someOtherTarget: action.payload }), // overrides the default onFailure() of PRIMARY_ACTION_FAILURE 25 | } 26 | } 27 | 28 | const completedReducer = completeReducer(reducerDescription); 29 | /* 30 | this is the final content of completedReducer: 31 | completedReducer === { 32 | [actions.PRIMARY_ACTION]: onLoading(), 33 | [actions.PRIMARY_ACTION_SUCCESS]: onSuccess(), 34 | [actions.PRIMARY_ACTION_FAILURE]: onFailure(), //this is overwritten afterwards 35 | [actions.POLLING_ACTION]: onLoading(), 36 | [actions.POLLING_ACTION_SUCCESS]: onSuccess(), 37 | [actions.POLLING_ACTION_FAILURE]: onFailure(), 38 | [actions.POLLING_ACTION_RETRY]: onRetry(), 39 | [actions.POLLING_ACTION_CANCEL]: onCancel(), 40 | [actions.IGNORED_ACTION]: (state, action) => ({ ...state, someTarget: action.payload }), 41 | [actions.CUSTOM_ACTION_COMPLETED]: (state, action) => ({ ...state, someOtherTarget: action.payload }), 42 | [actions.PRIMARY_ACTION_FAILURE]: (state, action) => ({ ...state, someOtherTarget: action.payload }) 43 | } 44 | */ 45 | 46 | const reducer = createReducer(initialState, completedReducer); 47 | ``` 48 | -------------------------------------------------------------------------------- /src/completers/completeReducer/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import onLoading from '../../effects/onLoading'; 3 | import onSuccess from '../../effects/onSuccess'; 4 | import onFailure from '../../effects/onFailure'; 5 | import onRetry from '../../effects/onRetry'; 6 | import onCancel from '../../effects/onCancel'; 7 | 8 | const schema = yup.object().required().shape({ 9 | primaryActions: yup.array().of(yup.string().typeError('primaryActions should be an array of strings')).typeError('primaryActions should be an array'), 10 | pollingActions: yup.array().of(yup.string().typeError('pollingActions should be an array of strings')).typeError('pollingActions should be an array'), 11 | override: yup.object() 12 | }).typeError('reducerDescription should be an object'); 13 | 14 | // Given a reducer description, it returns a reducerHandler with all success and failure cases 15 | function completeReducer(reducerDescription) { 16 | schema.validateSync(reducerDescription); 17 | const { 18 | primaryActions = [], pollingActions = [], override = {} 19 | } = reducerDescription; 20 | 21 | const reducerHandler = {}; 22 | primaryActions.forEach(actionName => { 23 | reducerHandler[actionName] = onLoading(); 24 | reducerHandler[`${actionName}_SUCCESS`] = onSuccess(); 25 | reducerHandler[`${actionName}_FAILURE`] = onFailure(); 26 | }); 27 | 28 | pollingActions.forEach(actionName => { 29 | reducerHandler[actionName] = onLoading(); 30 | reducerHandler[`${actionName}_SUCCESS`] = onSuccess(); 31 | reducerHandler[`${actionName}_FAILURE`] = onFailure(); 32 | reducerHandler[`${actionName}_RETRY`] = onRetry(); 33 | reducerHandler[`${actionName}_CANCEL`] = onCancel(); 34 | }); 35 | 36 | return { ...reducerHandler, ...override }; 37 | } 38 | 39 | export default completeReducer; 40 | -------------------------------------------------------------------------------- /src/completers/completeReducer/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | import onFailure from '../../effects/onFailure'; 5 | 6 | import completeReducer from '.'; 7 | 8 | const initialState = { 9 | target: 1, 10 | targetLoading: false, 11 | targetError: null, 12 | pollingTarget: 5, 13 | pollingTargetLoading: false, 14 | pollingTargetError: null, 15 | pollingTargetRetryCount: 0, 16 | pollingTargetTimeoutID: null, 17 | pollingTargetIsRetrying: false 18 | }; 19 | 20 | const setUp = { 21 | state: null 22 | }; 23 | 24 | beforeEach(() => { 25 | setUp.state = Immutable(initialState); 26 | }); 27 | 28 | describe('completeReducer', () => { 29 | it('Throws if a reducer description is not present', () => { 30 | expect(() => completeReducer(null)).toThrow(new Error('reducerDescription should be an object')); 31 | expect(() => completeReducer()).toThrow(); 32 | }); 33 | it('Throws if primary actions is not an array of strings', () => { 34 | expect(() => completeReducer({ primaryActions: 1 })).toThrow(new Error('primaryActions should be an array')); 35 | expect(() => completeReducer({ primaryActions: [null, 'thing'] })).toThrow(new Error('primaryActions should be an array of strings')); 36 | }); 37 | it('Extends correctly the primary actions', () => { 38 | const reducerDescription = { 39 | primaryActions: ['@NAMESPACE/ACTION'] 40 | }; 41 | const reducer = createReducer(setUp.state, completeReducer(reducerDescription)); 42 | // onLoading for common action 43 | setUp.state = reducer(setUp.state, { type: '@NAMESPACE/ACTION', target: 'target' }); 44 | expect(setUp.state.targetLoading).toBe(true); 45 | expect(setUp.state.targetError).toBeNull(); 46 | expect(setUp.state.target).toBe(1); 47 | 48 | // onSuccess behavior 49 | setUp.state = reducer(setUp.state, { type: '@NAMESPACE/ACTION_SUCCESS', target: 'target', payload: 42 }); 50 | expect(setUp.state.targetLoading).toBe(false); 51 | expect(setUp.state.targetError).toBeNull(); 52 | expect(setUp.state.target).toBe(42); 53 | 54 | // yet another onLoading 55 | setUp.state = reducer(setUp.state, { type: '@NAMESPACE/ACTION', target: 'target' }); 56 | expect(setUp.state.targetLoading).toBe(true); 57 | expect(setUp.state.targetError).toBeNull(); 58 | expect(setUp.state.target).toBe(42); 59 | 60 | // onFailure behavior 61 | setUp.state = reducer(setUp.state, { type: '@NAMESPACE/ACTION_FAILURE', target: 'target', payload: 'Oops !' }); 62 | expect(setUp.state.targetLoading).toBe(false); 63 | expect(setUp.state.targetError).toBe('Oops !'); 64 | expect(setUp.state.target).toBe(42); 65 | }); 66 | it('Overrides actions correctly', () => { 67 | const reducerDescription = { 68 | primaryActions: ['@NAMESPACE/ACTION'], 69 | override: { 70 | '@NAMESPACE/ACTION_FAILURE': onFailure(action => action.payload.message), 71 | '@NAMESPACE/ANOTHER': onFailure() 72 | } 73 | }; 74 | const reducer = createReducer(setUp.state, completeReducer(reducerDescription)); 75 | setUp.state = reducer(setUp.state, { type: '@NAMESPACE/ACTION_FAILURE', target: 'target', payload: { message: 'ERror MACro' } }); 76 | expect(setUp.state.targetError).toBe('ERror MACro'); 77 | setUp.state = reducer(setUp.state, { type: '@NAMESPACE/ANOTHER', target: 'target', payload: 'Also known as Ermac' }); 78 | expect(setUp.state.targetError).toBe('Also known as Ermac'); 79 | // Flawless victory 80 | }); 81 | it('Completes polling successfully', () => { 82 | const reducerDescription = { 83 | pollingActions: ['@NAMESPACE/POLLING'] 84 | }; 85 | const reducer = createReducer(setUp.state, completeReducer(reducerDescription)); 86 | 87 | const basePollingAction = { 88 | type: '@NAMESPACE/POLLING', 89 | target: 'pollingTarget', 90 | shouldRetry: () => false 91 | }; 92 | 93 | setUp.state = reducer(setUp.state, basePollingAction); 94 | expect(setUp.state.pollingTargetIsRetrying).toBe(false); 95 | expect(setUp.state.pollingTargetLoading).toBe(true); 96 | expect(setUp.state.pollingTargetError).toBeNull(); 97 | expect(setUp.state.pollingTarget).toBe(5); 98 | expect(setUp.state.pollingTargetRetryCount).toBe(0); 99 | expect(setUp.state.pollingTargetTimeoutID).toBeNull(); 100 | 101 | // onRetry 102 | setUp.state = reducer(setUp.state, { 103 | type: '@NAMESPACE/POLLING_RETRY', 104 | target: 'pollingTarget', 105 | payload: { 106 | timeoutID: 1, 107 | error: 'Oopsie' 108 | } 109 | }); 110 | expect(setUp.state.pollingTargetIsRetrying).toBe(true); 111 | expect(setUp.state.pollingTargetLoading).toBe(false); 112 | expect(setUp.state.pollingTargetError).toBe('Oopsie'); 113 | expect(setUp.state.pollingTarget).toBe(5); 114 | expect(setUp.state.pollingTargetRetryCount).toBe(1); 115 | expect(setUp.state.pollingTargetTimeoutID).toBe(1); 116 | 117 | setUp.state = reducer(setUp.state, basePollingAction); 118 | expect(setUp.state.pollingTargetIsRetrying).toBe(true); 119 | expect(setUp.state.pollingTargetLoading).toBe(true); 120 | expect(setUp.state.pollingTargetError).toBe('Oopsie'); 121 | expect(setUp.state.pollingTarget).toBe(5); 122 | expect(setUp.state.pollingTargetRetryCount).toBe(1); 123 | expect(setUp.state.pollingTargetTimeoutID).toBe(1); 124 | 125 | // onSuccess 126 | setUp.state = reducer(setUp.state, { 127 | type: '@NAMESPACE/POLLING_SUCCESS', 128 | target: 'pollingTarget', 129 | payload: 50, 130 | isPolling: true 131 | }); 132 | expect(setUp.state.pollingTargetIsRetrying).toBe(false); 133 | expect(setUp.state.pollingTargetLoading).toBe(false); 134 | expect(setUp.state.pollingTargetError).toBeNull(); 135 | expect(setUp.state.pollingTarget).toBe(50); 136 | expect(setUp.state.pollingTargetRetryCount).toBe(0); 137 | expect(setUp.state.pollingTargetTimeoutID).toBe(1); 138 | }); 139 | it('Completes polling unsuccessfully', () => { 140 | const reducerDescription = { 141 | pollingActions: ['@NAMESPACE/POLLING'] 142 | }; 143 | const reducer = createReducer(setUp.state, completeReducer(reducerDescription)); 144 | 145 | const basePollingAction = { 146 | type: '@NAMESPACE/POLLING', 147 | target: 'pollingTarget', 148 | shouldRetry: () => false 149 | }; 150 | 151 | setUp.state = reducer(setUp.state, basePollingAction); 152 | expect(setUp.state.pollingTargetIsRetrying).toBe(false); 153 | expect(setUp.state.pollingTargetLoading).toBe(true); 154 | expect(setUp.state.pollingTargetError).toBeNull(); 155 | expect(setUp.state.pollingTarget).toBe(5); 156 | expect(setUp.state.pollingTargetRetryCount).toBe(0); 157 | expect(setUp.state.pollingTargetTimeoutID).toBeNull(); 158 | 159 | // onRetry 160 | setUp.state = reducer(setUp.state, { 161 | type: '@NAMESPACE/POLLING_RETRY', 162 | target: 'pollingTarget', 163 | payload: { 164 | timeoutID: 1, 165 | error: 'Oopsie' 166 | } 167 | }); 168 | expect(setUp.state.pollingTargetIsRetrying).toBe(true); 169 | expect(setUp.state.pollingTargetLoading).toBe(false); 170 | expect(setUp.state.pollingTargetError).toBe('Oopsie'); 171 | expect(setUp.state.pollingTarget).toBe(5); 172 | expect(setUp.state.pollingTargetRetryCount).toBe(1); 173 | expect(setUp.state.pollingTargetTimeoutID).toBe(1); 174 | 175 | setUp.state = reducer(setUp.state, basePollingAction); 176 | expect(setUp.state.pollingTargetIsRetrying).toBe(true); 177 | expect(setUp.state.pollingTargetLoading).toBe(true); 178 | expect(setUp.state.pollingTargetError).toBe('Oopsie'); 179 | expect(setUp.state.pollingTarget).toBe(5); 180 | expect(setUp.state.pollingTargetRetryCount).toBe(1); 181 | expect(setUp.state.pollingTargetTimeoutID).toBe(1); 182 | 183 | // onFailure 184 | setUp.state = reducer(setUp.state, { 185 | type: '@NAMESPACE/POLLING_FAILURE', 186 | target: 'pollingTarget', 187 | payload: 33, 188 | isPolling: true 189 | }); 190 | expect(setUp.state.pollingTargetIsRetrying).toBe(false); 191 | expect(setUp.state.pollingTargetLoading).toBe(false); 192 | expect(setUp.state.pollingTargetError).toBe(33); 193 | expect(setUp.state.pollingTarget).toBe(5); 194 | expect(setUp.state.pollingTargetRetryCount).toBe(0); 195 | expect(setUp.state.pollingTargetTimeoutID).toBe(1); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /src/completers/completeState/README.md: -------------------------------------------------------------------------------- 1 | ## completeState - Completer 2 | 3 | This completer can extend a state description, helping to reduce its code size or programatically generate an initial redux state. 4 | 5 | A common pattern is to have a field associated with its Error and its Loading, so this completer adds `Loading` and `Error` extensions to the state for every field in the `description` argument. It also receives `customCompleters` to programatically generate the initial state and `ignoredTargets` that are added to the state without any changes. 6 | 7 | * `description`: The completer generates the `Loading` (default `false`) and `Error` (default `null`) states. 8 | * `ignoredTargets`: The completer doesn't generate any extra states for these. 9 | * `customCompleters`: You can specify what states to generate. 10 | * `pollingTargets`: The completer generates the `Loading` (default `false`), `Error` (default `null`), `IsRetrying` (default `false`), `RetryCount` (default `0`) and `TimeoutID` (default `null`) states. 11 | 12 | 13 | ### Example: 14 | ```js 15 | const initialState = completeState({ 16 | description: { 17 | firstCompleteState: 123, 18 | secondCompleteState: 456 19 | }, 20 | ignoredTargets: { 21 | firstIgnoredState: 1, 22 | secondIgnoredState: 2 23 | }, 24 | pollingtargets: { 25 | pollingState: 10 26 | }, 27 | customCompleters: [ 28 | { 29 | completer: (target, index) => ({ 30 | [target]: "I'm a custom state", 31 | [`${target}Customized${index}`]: 'Yeah! Custom' 32 | }), 33 | targets: ['firstCustomState', 'secondCustomState'] 34 | }, 35 | { 36 | completer: target => ({ 37 | [`${target}Cool`]: "I'm custom as well" 38 | }), 39 | targets: ['anotherCustomState'] 40 | } 41 | ] 42 | }); 43 | 44 | /* 45 | this is the final content of initialState: 46 | 47 | initialState === { 48 | firstCompleteState: 123, 49 | firstCompleteStateLoading: false, 50 | firstCompleteStateError: null, 51 | secondCompleteState: 456, 52 | secondCompleteStateLoading: false, 53 | secondCompleteStateError: null, 54 | pollingState: 10, 55 | pollingStateLoading: false, 56 | pollingStateError: null, 57 | pollingStateIsRetrying: false, 58 | pollingStateRetryCount: 0, 59 | pollingStateTimeoutID: null, 60 | firstIgnoredState: 1, 61 | secondIgnoredState: 2, 62 | firstCustomState: "I'm a custom state", 63 | firstCustomStateCustomized0: 'Yeah! Custom', 64 | secondCustomState: "I'm a custom state", 65 | secondCustomStateCustomized1: 'Yeah! Custom', 66 | anotherCustomStateCool: "I'm custom as well" 67 | }; 68 | */ 69 | ``` 70 | -------------------------------------------------------------------------------- /src/completers/completeState/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const schema = yup.object().shape({ 4 | description: yup.object().typeError('description should be an object'), 5 | targetCompleters: yup.array().of(yup.object().shape({ 6 | targets: yup.array().of(yup.string()), 7 | completer: yup.mixed().test(value => typeof value === 'function') 8 | }).typeError('targetCompleters should be an array of objects')).typeError('targetCompleters should be an array'), 9 | ignoredTargets: yup.object().typeError('ignoredTargets should be an object'), 10 | pollingTargets: yup.object().typeError('pollingTargets should be an object') 11 | }); 12 | 13 | function customComplete(targetCompleters) { 14 | return targetCompleters.flatMap(({ completer, targets }) => targets 15 | .map(completer)) 16 | .reduce((acc, value) => ({ ...acc, ...value }), {}); 17 | } 18 | 19 | function completeState(params) { 20 | schema.validateSync(params); 21 | const { 22 | description = {}, targetCompleters = [], ignoredTargets = {}, pollingTargets = {} 23 | } = params; 24 | 25 | const primaryState = customComplete([{ 26 | targets: Object.keys(description), 27 | completer: key => ({ 28 | [key]: description[key], 29 | [`${key}Loading`]: false, 30 | [`${key}Error`]: null 31 | }) 32 | }]); 33 | 34 | const pollingState = customComplete([{ 35 | targets: Object.keys(pollingTargets), 36 | completer: key => ({ 37 | [key]: pollingTargets[key], 38 | [`${key}Loading`]: false, 39 | [`${key}Error`]: null, 40 | [`${key}IsRetrying`]: false, 41 | [`${key}RetryCount`]: 0, 42 | [`${key}TimeoutID`]: null 43 | }) 44 | }]); 45 | 46 | const customCompleters = customComplete(targetCompleters); 47 | 48 | return { 49 | ...primaryState, ...pollingState, ...customCompleters, ...ignoredTargets 50 | }; 51 | } 52 | 53 | export default completeState; 54 | -------------------------------------------------------------------------------- /src/completers/completeState/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import completeState from '.'; 4 | 5 | const initialState = { 6 | target: 1, 7 | otherTarget: 2 8 | }; 9 | 10 | const setUp = { 11 | state: null 12 | }; 13 | 14 | beforeEach(() => { 15 | setUp.state = Immutable(initialState); 16 | }); 17 | 18 | describe('completeState', () => { 19 | it('Extends all fields by default', () => { 20 | const completedState = completeState({ description: setUp.state }); 21 | expect(completedState).toEqual({ 22 | target: 1, 23 | targetLoading: false, 24 | targetError: null, 25 | otherTarget: 2, 26 | otherTargetLoading: false, 27 | otherTargetError: null 28 | }); 29 | }); 30 | 31 | it('Only extends fields that are not ignored', () => { 32 | const completedState = completeState({ 33 | description: setUp.state, 34 | ignoredTargets: { ignoredTargetsKey: 'ignoredTargetsValue' } 35 | }); 36 | expect(completedState).toEqual({ 37 | target: 1, 38 | targetLoading: false, 39 | targetError: null, 40 | otherTarget: 2, 41 | otherTargetLoading: false, 42 | otherTargetError: null, 43 | ignoredTargetsKey: 'ignoredTargetsValue' 44 | }); 45 | }); 46 | 47 | it('Extends all polling fields', () => { 48 | const completedState = completeState({ description: setUp.state, pollingTargets: { myPollingTarget: 3 } }); 49 | expect(completedState).toEqual({ 50 | target: 1, 51 | targetLoading: false, 52 | targetError: null, 53 | otherTarget: 2, 54 | otherTargetLoading: false, 55 | otherTargetError: null, 56 | myPollingTarget: 3, 57 | myPollingTargetLoading: false, 58 | myPollingTargetError: null, 59 | myPollingTargetIsRetrying: false, 60 | myPollingTargetRetryCount: 0, 61 | myPollingTargetTimeoutID: null 62 | }); 63 | }); 64 | 65 | it('Throws if an initial state is not a object', () => { 66 | expect(() => completeState({ description: null })).toThrow(new Error('description should be an object')); 67 | expect(() => completeState({ description: 3 })).toThrow(new Error('description should be an object')); 68 | }); 69 | 70 | it('Throws if ignored targets is not a object', () => { 71 | expect(() => completeState({ description: {}, ignoredTargets: [] })).toThrow(new Error('ignoredTargets should be an object')); 72 | expect(() => completeState({ description: {}, ignoredTargets: 3 })).toThrow(new Error('ignoredTargets should be an object')); 73 | }); 74 | 75 | it('Throws if polling targets is not a object', () => { 76 | expect(() => completeState({ description: {}, pollingTargets: [] })).toThrow(new Error('pollingTargets should be an object')); 77 | expect(() => completeState({ description: {}, pollingTargets: 3 })).toThrow(new Error('pollingTargets should be an object')); 78 | }); 79 | 80 | it('Throws if targetCompleters is not an object array', () => { 81 | expect(() => completeState({ description: {}, targetCompleters: 3 })) 82 | .toThrow(new Error('targetCompleters should be an array')); 83 | expect(() => completeState({ description: {}, targetCompleters: [1, {}, 1, null] })) 84 | .toThrow(new Error('targetCompleters should be an array of objects')); 85 | expect(() => completeState({ description: {}, targetCompleters: [{ targets: ['1'], completer: 1 }] })) 86 | .toThrow(); 87 | }); 88 | 89 | it('Should complete in a custom way `otherTarget`', () => { 90 | const completedState = completeState({ 91 | description: setUp.state, 92 | ignoredTargets: { ignoredTarget: 'ignoredTarget' }, 93 | targetCompleters: [ 94 | { 95 | completer: (target, index) => ({ 96 | [target]: 100, 97 | [`${target}Customized${index}`]: true 98 | }), 99 | targets: ['firstTarget', 'secondTarget'] 100 | }, 101 | { 102 | completer: target => ({ 103 | [target]: 200, 104 | [`${target}CustomizedAgain`]: true 105 | }), 106 | targets: ['thirdTarget'] 107 | } 108 | ] 109 | }); 110 | expect(completedState).toEqual({ 111 | target: 1, 112 | targetLoading: false, 113 | targetError: null, 114 | otherTarget: 2, 115 | otherTargetLoading: false, 116 | otherTargetError: null, 117 | ignoredTarget: 'ignoredTarget', 118 | firstTarget: 100, 119 | firstTargetCustomized0: true, 120 | secondTarget: 100, 121 | secondTargetCustomized1: true, 122 | thirdTarget: 200, 123 | thirdTargetCustomizedAgain: true 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/completers/completeTypes/README.md: -------------------------------------------------------------------------------- 1 | ## completeTypes - Completer 2 | 3 | This completer can extend the list of possible action types, helping to reduce its code size or generate them programatically. `completeTypes` receives an object with: 4 | 5 | * `primaryActions`: The completer generates the `_SUCCESS` and `_FAILURE` actions. 6 | * `ignoredActions`: The completer doesn't generate any extra actions for these. 7 | * `pollingActions`: The completer generates the `_SUCCESS`, `_FAILURE`, `_RETRY` and `_CANCEL` actions. 8 | * `customCompleters`: You can specify what types to generate. 9 | 10 | ### Example: 11 | ```js 12 | const completedActions = completeTypes({ 13 | primaryActions: ['FIRST_PRIMARY_ACTION', 'SECOND_PRIMARY_ACTION'], 14 | ignoredActions: ['FIRST_IGNORED_ACTION', 'SECOND_IGNORED_ACTION'], 15 | pollingActions: ['FIRST_POLLING_ACTION', 'SECOND_POLLING_ACTION'], 16 | customCompleters: [ 17 | { completer: type => [type, `UPGRADED_${type}`], actions: ['FIRST_CUSTOM_ACTION', 'SECOND_CUSTOM_ACTION'] }, 18 | { completer: type => [`NEW_${type}`], actions: ['THIRD_CUSTOM_ACTION'] }, 19 | ]}); 20 | 21 | /* 22 | this is the final content of completedReducer: 23 | completedActions === [ 24 | "FIRST_PRIMARY_ACTION", 25 | "FIRST_PRIMARY_ACTION_SUCCESS", 26 | "FIRST_PRIMARY_ACTION_FAILURE", 27 | "SECOND_PRIMARY_ACTION", 28 | "SECOND_PRIMARY_ACTION_SUCCESS", 29 | "SECOND_PRIMARY_ACTION_FAILURE", 30 | "FIRST_POLLING_ACTION", 31 | "FIRST_POLLING_ACTION_SUCCESS", 32 | "FIRST_POLLING_ACTION_FAILURE", 33 | "FIRST_POLLING_ACTION_RETRY", 34 | "FIRST_POLLING_ACTION_CANCEL", 35 | "SECOND_POLLING_ACTION", 36 | "SECOND_POLLING_ACTION_SUCCESS", 37 | "SECOND_POLLING_ACTION_FAILURE", 38 | "SECOND_POLLING_ACTION_RETRY", 39 | "SECOND_POLLING_ACTION_CANCEL", 40 | "FIRST_CUSTOM_ACTION", 41 | "UPGRADED_FIRST_CUSTOM_ACTION", 42 | "SECOND_CUSTOM_ACTION", 43 | "UPGRADED_SECOND_CUSTOM_ACTION", 44 | "NEW_THIRD_CUSTOM_ACTION", 45 | "FIRST_IGNORED_ACTION", 46 | "SECOND_IGNORED_ACTION" 47 | */ 48 | 49 | const actions = createTypes(completedActions, '@@NAMESPACE') 50 | ``` 51 | -------------------------------------------------------------------------------- /src/completers/completeTypes/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const customCompleter = (typesCompleters) => typesCompleters.flatMap(({ actions, completer }) => actions.flatMap(completer)); 4 | 5 | const schema = yup.object().shape({ 6 | primaryActions: yup.array().of(yup.string().typeError('primaryActions should be an array of strings')).typeError('primaryActions should be an array'), 7 | ignoredActions: yup.array().of(yup.string().typeError('ignoredActions should be an array of strings')).typeError('ignoredActions should be an array'), 8 | pollingActions: yup.array().of(yup.string().typeError('pollingActions should be an array of strings')).typeError('pollingActions should be an array'), 9 | customCompleters: yup.array().of(yup.object().shape({ 10 | completer: yup.mixed().test(value => typeof value === 'function').typeError('completer should be a function'), 11 | actions: yup.array().of(yup.string().typeError('actions should be an array of strings')).typeError('actions should be an array') 12 | })) 13 | }).typeError('reducerDescription should be an object'); 14 | 15 | function completeTypes(params) { 16 | schema.validateSync(params); 17 | const { 18 | primaryActions = [], ignoredActions = [], customCompleters = [], pollingActions = [] 19 | } = params; 20 | 21 | const primaryTypes = customCompleter([{ actions: primaryActions, completer: type => [type, `${type}_SUCCESS`, `${type}_FAILURE`] }]); 22 | const pollingTypes = customCompleter([{ actions: pollingActions, completer: type => [type, `${type}_SUCCESS`, `${type}_FAILURE`, `${type}_RETRY`, `${type}_CANCEL`] }]); 23 | const customCompletedTypes = customCompleter(customCompleters); 24 | return [...primaryTypes, ...pollingTypes, ...customCompletedTypes, ...ignoredActions]; 25 | } 26 | 27 | export default completeTypes; 28 | -------------------------------------------------------------------------------- /src/completers/completeTypes/test.js: -------------------------------------------------------------------------------- 1 | import completeTypes from '.'; 2 | 3 | describe('completeTypes', () => { 4 | it("Completes from an array's element", () => { 5 | const arrTypes = ['AN_ACTION']; 6 | expect(completeTypes({ primaryActions: arrTypes })).toEqual(['AN_ACTION', 'AN_ACTION_SUCCESS', 'AN_ACTION_FAILURE']); 7 | }); 8 | it('Completes from an array of multiple elements', () => { 9 | const arrTypes = ['AN_ACTION', 'OTHER_ACTION', 'ANOTHER_ACTION']; 10 | expect(completeTypes({ primaryActions: arrTypes })).toEqual([ 11 | 'AN_ACTION', 12 | 'AN_ACTION_SUCCESS', 13 | 'AN_ACTION_FAILURE', 14 | 'OTHER_ACTION', 15 | 'OTHER_ACTION_SUCCESS', 16 | 'OTHER_ACTION_FAILURE', 17 | 'ANOTHER_ACTION', 18 | 'ANOTHER_ACTION_SUCCESS', 19 | 'ANOTHER_ACTION_FAILURE' 20 | ]); 21 | }); 22 | it('Does not complete from exception cases', () => { 23 | const arrActions = ['AN_ACTION']; 24 | const exceptionCases = ['EXCEPT_ACTION']; 25 | expect(completeTypes({ primaryActions: arrActions, ignoredActions: exceptionCases })).toEqual([ 26 | 'AN_ACTION', 27 | 'AN_ACTION_SUCCESS', 28 | 'AN_ACTION_FAILURE', 29 | 'EXCEPT_ACTION' 30 | ]); 31 | }); 32 | it('Custom completers completes all types passed', () => { 33 | const primaryActions = []; 34 | const ignoredActions = []; 35 | const completer = type => [type, `${type}_SUCCESS`, `${type}_FAILURE`]; 36 | const customCompleters = [ 37 | { completer, actions: ['CUSTOM_ACTION'] } 38 | ]; 39 | expect(completeTypes({ primaryActions, ignoredActions, customCompleters })).toEqual([ 40 | 'CUSTOM_ACTION', 41 | 'CUSTOM_ACTION_SUCCESS', 42 | 'CUSTOM_ACTION_FAILURE' 43 | ]); 44 | }); 45 | it('Polling actions completes', () => { 46 | const pollingActions = ['FETCH_1', 'FETCH_2']; 47 | expect(completeTypes({ pollingActions })).toEqual([ 48 | 'FETCH_1', 49 | 'FETCH_1_SUCCESS', 50 | 'FETCH_1_FAILURE', 51 | 'FETCH_1_RETRY', 52 | 'FETCH_1_CANCEL', 53 | 'FETCH_2', 54 | 'FETCH_2_SUCCESS', 55 | 'FETCH_2_FAILURE', 56 | 'FETCH_2_RETRY', 57 | 'FETCH_2_CANCEL' 58 | ]); 59 | }); 60 | it('Throws if parameters are not the expected ones', () => { 61 | expect(() => completeTypes({ primaryActions: null })).toThrow(new Error('primaryActions should be an array')); 62 | expect(() => completeTypes({ primaryActions: [null] })).toThrow(new Error('primaryActions should be an array of strings')); 63 | expect(() => completeTypes({ primaryActions: ['ONE'], ignoredActions: null })).toThrow(new Error('ignoredActions should be an array')); 64 | expect(() => completeTypes({ primaryActions: ['ONE'], ignoredActions: [null] })).toThrow(new Error('ignoredActions should be an array of strings')); 65 | expect(() => completeTypes({ 66 | primaryActions: ['ONE'], 67 | ignoredActions: ['TWO'], 68 | customCompleters: [{ 69 | actions: null 70 | }] 71 | })).toThrow(new Error('actions should be an array')); 72 | expect(() => completeTypes({ 73 | primaryActions: ['ONE'], 74 | ignoredActions: ['TWO'], 75 | customCompleters: [{ 76 | actions: [null] 77 | }] 78 | })).toThrow(new Error('actions should be an array of strings')); 79 | expect(() => completeTypes({ 80 | primaryActions: ['ONE'], 81 | ignoredActions: ['TWO'], 82 | customCompleters: [{ 83 | actions: ['THREE'], 84 | completer: null 85 | }] 86 | })).toThrow(); 87 | expect(() => completeTypes({ pollingActions: null }).toThrow(new Error('pollingActions should be an array'))); 88 | expect(() => completeTypes({ pollingActions: [null] }).toThrow(new Error('pollingActions should be an array of strings'))); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/configuration/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-underscore-dangle 2 | global.__redux_recompose_merge = (state, content) => ({ ...state, ...content }); 3 | // global.__redux_recompose_merge = (state, content) => state.merge(content); 4 | 5 | // This will be exported as configureMergeState at main file 6 | export default modifier => { 7 | // eslint-disable-next-line no-underscore-dangle 8 | global.__redux_recompose_merge = modifier; 9 | }; 10 | 11 | // eslint-disable-next-line no-underscore-dangle 12 | export const mergeState = (state, content) => global.__redux_recompose_merge(state, content); 13 | -------------------------------------------------------------------------------- /src/configuration/test.js: -------------------------------------------------------------------------------- 1 | import configureMergeState, { mergeState } from '.'; 2 | 3 | describe('configureMergeState', () => { 4 | it('Should allow state configuration', () => { 5 | configureMergeState((state, content) => ({ ...state, ...content })); 6 | const state = {}; 7 | const modifiedState = mergeState(state, { content: 'type' }); 8 | expect(modifiedState).toEqual({ content: 'type' }); 9 | expect(modifiedState === state).toBe(false); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/creators/createReducer/README.md: -------------------------------------------------------------------------------- 1 | ## createReducer - Creator 2 | 3 | This function allows us to create our own reducer based on an object. 4 | 5 | Receives an initialState and a reducer description. 6 | Example: 7 | ```js 8 | const reducerDescription = { 9 | 'ACTION_NAME': (state, action) => ({ ...state, aTarget: ':)' }) 10 | } 11 | 12 | const initialState = { aTarget: null }; 13 | export default createReducer(initialState, reducerDescription); 14 | ``` 15 | 16 | So, we may do: 17 | ```js 18 | dispatch({ type: 'ACTION_NAME' }); 19 | ``` 20 | 21 | And then the state will be like: 22 | ```js 23 | state = { 24 | aTarget: ':)' 25 | }; 26 | ``` 27 | -------------------------------------------------------------------------------- /src/creators/createReducer/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const schema = yup.object().required('reducerDescription is required').typeError('reducerDescription should be an object'); 4 | 5 | function createReducer(initialState, reducerDescription) { 6 | schema.validateSync(reducerDescription); 7 | 8 | return (state = initialState, action) => { 9 | if (!action.type) { 10 | console.warn(`Handling an action without type: ${JSON.stringify(action)}`); 11 | } 12 | const handler = reducerDescription[action.type]; 13 | return (handler && handler(state, action)) || state; 14 | }; 15 | } 16 | 17 | export default createReducer; 18 | -------------------------------------------------------------------------------- /src/creators/createReducer/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '.'; 4 | 5 | const initialState = { 6 | count: 0 7 | }; 8 | 9 | const setUp = { 10 | state: null 11 | }; 12 | 13 | const actions = { DECREMENT: 'decrement', INCREMENT: 'increment', DUMMY: 'dummy' }; 14 | 15 | const dummyAction = { type: actions.DUMMY }; 16 | const decrementAction = { type: actions.DECREMENT, target: 'count' }; 17 | const incrementAction = { type: actions.INCREMENT, target: 'count' }; 18 | 19 | beforeEach(() => { 20 | setUp.state = Immutable(initialState); 21 | }); 22 | 23 | describe('createReducer', () => { 24 | it('Throws if no reducer description is passed', () => { 25 | expect(() => createReducer({})).toThrowError(new Error('reducerDescription is required')); 26 | }); 27 | it('Throws if a non object reducer description is passed', () => { 28 | expect(() => createReducer({}, null)).toThrowError(new Error('reducerDescription should be an object')); 29 | expect(() => createReducer({}, [])).toThrowError(new Error('reducerDescription should be an object')); 30 | }); 31 | it('Initializes state correctly', () => { 32 | const reducerDescription = {}; 33 | const reducer = createReducer(setUp.state, reducerDescription); 34 | expect(reducer(setUp.state, dummyAction).count).toBe(setUp.state.count); 35 | }); 36 | it('Does not handle unknown actions', () => { 37 | const reducerDescription = { 38 | [actions.INCREMENT]: state => state.merge({ count: state.count + 1 }) 39 | }; 40 | const reducer = createReducer(setUp.state, reducerDescription); 41 | setUp.state = reducer(setUp.state, incrementAction); 42 | setUp.state = reducer(setUp.state, decrementAction); 43 | setUp.state = reducer(setUp.state, incrementAction); 44 | expect(setUp.state.count).toBe(2); 45 | }); 46 | it('Does handle multiple actions', () => { 47 | const reducerDescription = { 48 | [actions.INCREMENT]: state => state.merge({ count: state.count + 1 }), 49 | [actions.DECREMENT]: state => state.merge({ count: state.count - 1 }) 50 | }; 51 | const reducer = createReducer(setUp.state, reducerDescription); 52 | setUp.state = reducer(setUp.state, incrementAction); 53 | setUp.state = reducer(setUp.state, decrementAction); 54 | setUp.state = reducer(setUp.state, incrementAction); 55 | expect(setUp.state.count).toBe(1); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/creators/createThunkAction/README.md: -------------------------------------------------------------------------------- 1 | ## createThunkAction - Creator 2 | 3 | This function describes a basic async action that fetches data. It describes the basis of `SUCCESS-FAILURE` pattern. 4 | 5 | As a result, returns a function that could dispatch up to three different actions. 6 | 7 | It receives four parameters: 8 | * type: the type of the action being dispatched initially. 9 | * target: the target being modified by each of these actions. 10 | * serviceCall: it is a function that returns a Promise. It is used on the `fetch` phase. 11 | * selector: this argument is optional. Receives the entire state and the result of this function will be passed to `serviceCall` as a parameter. 12 | 13 | Example: 14 | ```js 15 | const asyncAction = createThunkAction( 16 | actions.FETCH, 17 | 'target', 18 | Service.GetStuff, 19 | state => state.stuff.id 20 | ); 21 | ``` 22 | 23 | Is conceptually equal to: 24 | ```js 25 | const asyncAction = async (dispatch, getState) => { 26 | dispatch({ type: actions.FETCH, target: 'target' }); 27 | const stuffId = getState().stuff.id; 28 | const response = await Service.GetStuff(stuffId); 29 | if (response.ok) { 30 | dispatch({ type: actions.FETCH_SUCCESS, target: 'target', payload: response.data }); 31 | } else { 32 | dispatch({ type: actions.FETCH_FAILURE, target: 'target', payload: response.problem }); 33 | } 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /src/creators/createThunkAction/index.js: -------------------------------------------------------------------------------- 1 | import composeInjections from '../../injections/composeInjections'; 2 | import baseThunkAction from '../../injections/baseThunkAction'; 3 | 4 | function createThunkAction(type, target, serviceCall, payload = () => {}) { 5 | console.warn('redux-recompose: createThunkAction is deprecated. Use fetch middleware instead.'); 6 | return composeInjections(baseThunkAction({ 7 | type, 8 | target, 9 | serviceCall, 10 | payload 11 | })); 12 | } 13 | 14 | export default createThunkAction; 15 | -------------------------------------------------------------------------------- /src/creators/createThunkAction/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../createTypes'; 3 | 4 | const MockService = { 5 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42 })), 6 | fetchFailure: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR' })) 7 | }; 8 | 9 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE'], '@TEST'); 10 | 11 | describe('createThunkAction', () => { 12 | it('Dispatches SUCCESS correctly', async () => { 13 | const store = mockStore({}); 14 | await store.dispatch({ type: actions.FETCH, target: 'aTarget', service: MockService.fetchSomething }); 15 | const actionsDispatched = store.getActions(); 16 | expect(actionsDispatched).toEqual([ 17 | { type: actions.FETCH, target: 'aTarget' }, 18 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 42 } 19 | ]); 20 | }); 21 | it('Dispatches FAILURE correctly', async () => { 22 | const store = mockStore({}); 23 | await store.dispatch({ type: actions.FETCH, target: 'aTarget', service: MockService.fetchFailure }); 24 | const actionsDispatched = store.getActions(); 25 | expect(actionsDispatched).toEqual([ 26 | { type: actions.FETCH, target: 'aTarget' }, 27 | { type: actions.FETCH_FAILURE, target: 'aTarget', payload: 'CLIENT_ERROR' } 28 | ]); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/creators/createTypes/README.md: -------------------------------------------------------------------------------- 1 | ## createTypes - Creator 2 | 3 | Receives a string list and another string to prepend a namespace. 4 | It builds an object with action names as constants. 5 | Example: 6 | ```js 7 | const actions = createTypes(['ACTION1', 'ACTION2'], '@@NAMESPACE'); 8 | ``` 9 | `actions` will be like: 10 | 11 | ```js 12 | const actions = { 13 | 'ACTION1': '@@NAMESPACE/ACTION1', 14 | 'ACTION2': '@@NAMESPACE/ACTION2' 15 | }; 16 | ``` 17 | -------------------------------------------------------------------------------- /src/creators/createTypes/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const schema = yup.array().of(yup.string().typeError('actionsArray should be an array of strings')).typeError('actionsArray should be an array'); 4 | 5 | /** 6 | * Receives an array of strings, and returns an obj with that strings as properties 7 | with that string as value. 8 | * E.G: 9 | * stringArrayToObject(['A', 'B', 'C']) // { A: 'A', B: 'B', C: 'C' } 10 | */ 11 | function stringArrayToObject(actionsArray, namespace) { 12 | schema.validateSync(actionsArray); 13 | 14 | const actionNames = {}; 15 | 16 | actionsArray.forEach(actionName => { 17 | actionNames[actionName] = namespace ? `${namespace}/${actionName}` : actionName; 18 | }); 19 | 20 | return actionNames; 21 | } 22 | 23 | function createTypes(actionsArray, namespace) { 24 | if (!namespace) console.warn('No namespace provided while creating action types'); 25 | return stringArrayToObject(actionsArray, namespace); 26 | } 27 | 28 | export default createTypes; 29 | -------------------------------------------------------------------------------- /src/creators/createTypes/test.js: -------------------------------------------------------------------------------- 1 | import createTypes from '.'; 2 | 3 | describe('createTypes', () => { 4 | it('Creates an empty types object from an empty array', () => { 5 | const types = createTypes([], '@@NAMESPACE'); 6 | expect(types).toEqual({}); 7 | }); 8 | it('Creates types object based on a string array', () => { 9 | const types = createTypes(['ONE', 'TWO'], '@@NAMESPACE'); 10 | expect(types).toEqual({ 11 | ONE: '@@NAMESPACE/ONE', 12 | TWO: '@@NAMESPACE/TWO' 13 | }); 14 | }); 15 | it('It throws if does not receive an array', () => { 16 | expect(() => createTypes({}, 'none')).toThrow(new Error('actionsArray should be an array')); 17 | }); 18 | it('It throws if does not receive a string array', () => { 19 | expect(() => createTypes(['s', {}], 'none')).toThrow(new Error('actionsArray should be an array of strings')); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/effects/onAppend/README.md: -------------------------------------------------------------------------------- 1 | ## onAppend - Effect 2 | 3 | This effect allow us to append an element to an array in the state. 4 | 5 | Example: 6 | 7 | ```js 8 | const initialState = { 9 | fibonacciArray: [1, 2, 3, 5, 8] 10 | }; 11 | 12 | const reducerDescription = { 13 | [actions.APPEND]: onAppend() 14 | }; 15 | 16 | export default createReducer(initialState, reducerDescription); 17 | ``` 18 | 19 | If we now do: 20 | `dispatch({ type: actions.APPEND, target: 'fibonacciArray', payload: 13 });` 21 | 22 | Then the state will be: 23 | 24 | ```js 25 | state = { 26 | fibonacciArray: [1, 2, 3, 5, 8, 13] 27 | }; 28 | ``` 29 | 30 | ### Custom selectors 31 | 32 | `onAppend` effect receives an optional parameter: 33 | 34 | - selector: This function describes how we read the value from the `action`. 35 | `(action, state) => any` 36 | By default, is: 37 | `action => action.payload` 38 | -------------------------------------------------------------------------------- /src/effects/onAppend/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onAppend(selector = action => action.payload) { 4 | return validate({ 5 | name: 'onAppend', 6 | realTarget: action => action.target, 7 | do: (action, state) => [...state[action.target], selector(action)] 8 | }); 9 | } 10 | 11 | export default onAppend; 12 | -------------------------------------------------------------------------------- /src/effects/onAppend/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onAppend from '.'; 6 | 7 | const initialState = { 8 | numberArray: [4, 2, 3, 4, 1] 9 | }; 10 | 11 | const setUp = { 12 | state: null 13 | }; 14 | 15 | beforeEach(() => { 16 | setUp.state = Immutable(initialState); 17 | }); 18 | 19 | describe('onAppend', () => { 20 | it('Appends integer to integer array', () => { 21 | const reducer = createReducer(setUp.state, { 22 | '@@ACTION/APPEND_INTEGER': onAppend() 23 | }); 24 | const newState = reducer(setUp.state, { 25 | type: '@@ACTION/APPEND_INTEGER', 26 | payload: 7, 27 | target: 'numberArray' 28 | }); 29 | expect(newState.numberArray).toEqual([4, 2, 3, 4, 1, 7]); 30 | }); 31 | 32 | it('Appends objects based on a payload', () => { 33 | const reducer = createReducer(setUp.state, { 34 | '@@ACTION/APPEND_INTEGER': onAppend(action => action.payload.id) 35 | }); 36 | const newState = reducer(setUp.state, { 37 | type: '@@ACTION/APPEND_INTEGER', 38 | payload: { id: 8 }, 39 | target: 'numberArray' 40 | }); 41 | expect(newState.numberArray).toEqual([4, 2, 3, 4, 1, 8]); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/effects/onCancel/README.md: -------------------------------------------------------------------------------- 1 | ## onCancel - Effect 2 | 3 | This effect cancels the polling flow. 4 | 5 | This effect is a multi-target effect - It modifies more than one target at the same time. 6 | 7 | It will: 8 | 9 | - Set `${action.target}IsRetrying` as `false` 10 | - Set `${action.target}Loading` as `false` 11 | - Set `${action.target}RetryCount` as `0` 12 | - Set `${action.target}Error` as `null` 13 | - Set `${action.target}TimeoutID` as `null` 14 | 15 | Example: 16 | 17 | ```js 18 | const reducerDescription = { 19 | 'CANCEL': onCancel() 20 | }; 21 | ``` 22 | -------------------------------------------------------------------------------- /src/effects/onCancel/index.js: -------------------------------------------------------------------------------- 1 | import { mergeState } from '../../configuration'; 2 | 3 | function onCancel() { 4 | return (state, action) => { 5 | const timeoutIDKey = `${action.target}TimeoutID`; 6 | clearTimeout(state[timeoutIDKey]); 7 | return mergeState(state, { 8 | [`${action.target}IsRetrying`]: false, 9 | [`${action.target}Loading`]: false, 10 | [`${action.target}RetryCount`]: 0, 11 | [`${action.target}Error`]: null, 12 | [timeoutIDKey]: null 13 | }); 14 | }; 15 | } 16 | 17 | export default onCancel; 18 | -------------------------------------------------------------------------------- /src/effects/onCancel/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onCancel from '.'; 6 | 7 | const initialState = { 8 | target: null, 9 | targetLoading: true, 10 | targetError: null, 11 | targetRetryCount: 0, 12 | targetTimeoutID: null, 13 | targetIsRetrying: false 14 | }; 15 | 16 | const setUp = { 17 | state: null 18 | }; 19 | 20 | beforeEach(() => { 21 | setUp.state = Immutable(initialState); 22 | }); 23 | 24 | describe('onCancel', () => { 25 | it('Sets targets', () => { 26 | const reducer = createReducer(setUp.state, { 27 | '@@ACTION/TYPE': onCancel() 28 | }); 29 | const newState = reducer(setUp.state, { 30 | type: '@@ACTION/TYPE', 31 | target: 'target', 32 | payload: 1 33 | }); 34 | expect(newState).toEqual({ 35 | target: null, 36 | targetLoading: false, 37 | targetError: null, 38 | targetRetryCount: 0, 39 | targetTimeoutID: null, 40 | targetIsRetrying: false 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/effects/onConcatenate/README.md: -------------------------------------------------------------------------------- 1 | ## onConcatenate - Effect 2 | 3 | This effect allow us to concatenate an array to an array in the state. This is useful for cases where you want to dynamically add elements to an array as you fetch them, like the case of an infinite scroll. 4 | 5 | Example: 6 | 7 | ```js 8 | const initialState = { 9 | numberArray: [1, 2, 3] 10 | }; 11 | 12 | const reducerDescription = { 13 | [actions.CONCATENATE]: onConcatenate() 14 | }; 15 | 16 | export default createReducer(initialState, reducerDescription); 17 | ``` 18 | 19 | If we now do: 20 | `dispatch({ type: actions.CONCATENATE, target: 'numberArray', payload: [4, 5] });` 21 | 22 | Then the state will be: 23 | 24 | ```js 25 | state = { 26 | numberArray: [1, 2, 3, 4, 5] 27 | }; 28 | ``` 29 | 30 | ### Custom selectors 31 | 32 | `onConcatenate` effect receives an optional parameter: 33 | 34 | - selector: This function describes how we read the value from the `action`. 35 | `(action, state) => any` 36 | By default, is: 37 | `action => action.payload` 38 | -------------------------------------------------------------------------------- /src/effects/onConcatenate/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onConcatenate(selector = action => action.payload) { 4 | return validate({ 5 | name: 'onConcatenate', 6 | realTarget: action => action.target, 7 | do: (action, state) => state[action.target].concat(selector(action)) 8 | }); 9 | } 10 | 11 | export default onConcatenate; 12 | -------------------------------------------------------------------------------- /src/effects/onConcatenate/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onConcatenate from '.'; 6 | 7 | const initialState = { 8 | numberArray: [1, 2, 3] 9 | }; 10 | 11 | const setUp = { 12 | state: null 13 | }; 14 | 15 | beforeEach(() => { 16 | setUp.state = Immutable(initialState); 17 | }); 18 | 19 | describe('onConcatenate', () => { 20 | it('Concatenates array to another array', () => { 21 | const reducer = createReducer(setUp.state, { 22 | '@@ACTION/CONCATENATE': onConcatenate() 23 | }); 24 | const newState = reducer(setUp.state, { 25 | type: '@@ACTION/CONCATENATE', 26 | payload: [4, 5], 27 | target: 'numberArray' 28 | }); 29 | expect(newState.numberArray).toEqual([1, 2, 3, 4, 5]); 30 | }); 31 | 32 | it('Concatenates array based on a payload', () => { 33 | const reducer = createReducer(setUp.state, { 34 | '@@ACTION/CONCATENATE': onConcatenate(action => action.payload.results) 35 | }); 36 | const newState = reducer(setUp.state, { 37 | type: '@@ACTION/CONCATENATE', 38 | payload: { results: [4, 5, 6] }, 39 | target: 'numberArray' 40 | }); 41 | expect(newState.numberArray).toEqual([1, 2, 3, 4, 5, 6]); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/effects/onCycle/README.md: -------------------------------------------------------------------------------- 1 | ## onCycle - Effect 2 | 3 | This effect allows to cycle an array as many positions as we indicate, in both directions. 4 | 5 | Example: 6 | 7 | ```js 8 | const initialState = { 9 | letterArray: ['A','B','C','D','E','F','G','H'] 10 | }; 11 | 12 | const reducerDescription = { 13 | [actions.CYCLE]: onCycle() 14 | }; 15 | 16 | export default createReducer(initialState, reducerDescription); 17 | ``` 18 | 19 | If we now do: 20 | `dispatch({ type: actions.CYCLE, target: 'letterArray', step: 2 });` 21 | 22 | Then the state will be: 23 | 24 | ```js 25 | state = { 26 | letterArray: ['C','D','E','F','G','H','A','B'] 27 | }; 28 | ``` 29 | Example 2: 30 | 31 | ```js 32 | const initialState = { 33 | letterArray: ['A','B','C','D','E','F','G','H'] 34 | }; 35 | 36 | const reducerDescription = { 37 | [actions.CYCLE]: onCycle() 38 | }; 39 | 40 | export default createReducer(initialState, reducerDescription); 41 | ``` 42 | 43 | If we now do: 44 | `dispatch({ type: actions.CYCLE, target: 'letterArray', step: -2 });` 45 | 46 | Then the state will be: 47 | 48 | ```js 49 | state = { 50 | letterArray: ['G','H','A','B','C','D','E','F'] 51 | }; 52 | ``` 53 | -------------------------------------------------------------------------------- /src/effects/onCycle/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onCycle() { 4 | return validate({ 5 | name: 'onCycle', 6 | realTarget: action => action.target, 7 | do: (action, state) => { 8 | const { step = 1, target } = action; 9 | const array = state[target]; 10 | const index = step > 0 ? step : array.length + step; 11 | return [...array.slice(index), ...array.slice(0, index)]; 12 | } 13 | }); 14 | } 15 | 16 | export default onCycle; 17 | -------------------------------------------------------------------------------- /src/effects/onCycle/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onCycle from '.'; 6 | 7 | const initialState = { 8 | letterArray: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] 9 | }; 10 | 11 | const setUp = { 12 | state: null 13 | }; 14 | 15 | beforeEach(() => { 16 | setUp.state = Immutable(initialState); 17 | }); 18 | 19 | describe('onCycle', () => { 20 | it('Cycle array two positions forward', () => { 21 | const reducer = createReducer(setUp.state, { 22 | '@@ACTION/CYCLE': onCycle() 23 | }); 24 | const newState = reducer(setUp.state, { 25 | type: '@@ACTION/CYCLE', 26 | target: 'letterArray', 27 | step: 2 28 | }); 29 | expect(newState.letterArray).toEqual(['C', 'D', 'E', 'F', 'G', 'H', 'A', 'B']); 30 | }); 31 | 32 | it('Cycling array two positions backwards', () => { 33 | const reducer = createReducer(setUp.state, { 34 | '@@ACTION/CYCLE': onCycle() 35 | }); 36 | const newState = reducer(setUp.state, { 37 | type: '@@ACTION/CYCLE', 38 | target: 'letterArray', 39 | step: -2 40 | }); 41 | expect(newState.letterArray).toEqual(['G', 'H', 'A', 'B', 'C', 'D', 'E', 'F']); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/effects/onDelete/README.md: -------------------------------------------------------------------------------- 1 | ## onDelete - Effect 2 | 3 | This effect allow us to delete items from arrays without mutations involved. 4 | These deletions are managed via `action.payload` by default. 5 | 6 | Example: 7 | 8 | We have this state: 9 | ```js 10 | const initialState = { 11 | objectList: [{ id: 1 }, { id: 2 }, { id: 3 }] 12 | }; 13 | ``` 14 | 15 | And we want to delete objects _by id_. Then, we'd like to write: 16 | 17 | ```js 18 | dispatch({ type: actions.DELETE_ITEM, payload: 2 }); 19 | ``` 20 | 21 | if we would like to delete the item with `{ id: 2 }`, leading to: 22 | 23 | `objectList: [{ id: 1 }, { id: 3 }]` 24 | 25 | To achieve that, we write this as a _reducer_: 26 | ```js 27 | const reducerDescription = { 28 | [actions.DELETE_ITEM]: onDelete() 29 | }; 30 | 31 | export default createReducer(initialState, reducerDescription); 32 | ``` 33 | 34 | 35 | ### Custom selectors 36 | 37 | `onDelete` effect actually takes three parameters that are optional: 38 | 39 | * leftSelector: How we are going to read the `action` for deletions; the identifier that we want to delete. 40 | `(action, item) => any` 41 | By default is 42 | `action => action.payload` 43 | * rightSelector: How we are going to read each one of the list items. 44 | `(item, action) => any` 45 | By default is 46 | `item => item.id` 47 | * filter: Actually, it is a comparison between the results of `leftSelector` and `rightSelector` calls. 48 | `(item, action) => bool` 49 | By default is: 50 | `(item, action) => action.payload !== item.id` 51 | -------------------------------------------------------------------------------- /src/effects/onDelete/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onDelete(leftSelector, rightSelector, filter) { 4 | const safeLeftSelector = leftSelector || (action => action.payload); 5 | const safeRightSelector = rightSelector || (item => item.id); 6 | const safeFilter = filter || ( 7 | (item, action) => safeLeftSelector(action, item) !== safeRightSelector(item, action) 8 | ); 9 | 10 | return validate({ 11 | name: 'onDelete', 12 | realTarget: action => action.target, 13 | do: (action, state) => state[`${action.target}`].filter(item => safeFilter(item, action)) 14 | }); 15 | } 16 | 17 | export default onDelete; 18 | -------------------------------------------------------------------------------- /src/effects/onDelete/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onDelete from '.'; 6 | 7 | const initialState = { 8 | numberList: [4, 2, 3, 4, 1], 9 | objectList: [{ id: 1 }, { id: 2 }, { id: 3 }], 10 | dragonList: [ 11 | { name: 'Ysera', color: 'green' }, 12 | { name: 'Nefarian', color: 'red' }, 13 | { name: 'Chromaggus', color: 'blue' }, 14 | { name: 'Alexstraza', color: 'gray' }, 15 | { name: 'Nozdormu', color: 'yellow' }, 16 | { name: 'DeathWing', color: 'black' }, 17 | { name: 'Red-Eyes Black Dragon', color: 'black' } 18 | ] 19 | }; 20 | 21 | const setUp = { 22 | state: null 23 | }; 24 | 25 | beforeEach(() => { 26 | setUp.state = Immutable(initialState); 27 | }); 28 | 29 | describe('onDelete', () => { 30 | it('Deletes objects based on IDs by default', () => { 31 | const reducer = createReducer(setUp.state, { 32 | '@@ACTION/FILTER_OBJECT': onDelete() 33 | }); 34 | const newState = reducer(setUp.state, { type: '@@ACTION/FILTER_OBJECT', payload: 2, target: 'objectList' }); 35 | expect(newState.objectList).toEqual([{ id: 1 }, { id: 3 }]); 36 | }); 37 | 38 | it('Deletes objects based on a payload', () => { 39 | const reducer = createReducer(setUp.state, { 40 | '@@ACTION/FILTER_OBJECT': onDelete(action => action.payload.id) 41 | }); 42 | const newState = reducer(setUp.state, { type: '@@ACTION/FILTER_OBJECT', payload: { id: 1 }, target: 'objectList' }); 43 | expect(newState.objectList).toEqual([{ id: 2 }, { id: 3 }]); 44 | }); 45 | 46 | it('Deletes objects based on items characteristics', () => { 47 | const reducer = createReducer(setUp.state, { 48 | // item => item.id is the item selector by default 49 | '@@ACTION/FILTER_NUMBER': onDelete(null, item => item) 50 | }); 51 | const newState = reducer(setUp.state, { type: '@@ACTION/FILTER_NUMBER', payload: 4, target: 'numberList' }); 52 | expect(newState.numberList).toEqual([2, 3, 1]); 53 | }); 54 | 55 | it('May combine action and item selectors with a custom comparison', () => { 56 | const reducer = createReducer(setUp.state, { 57 | '@@ACTION/FILTER_DRAGONS': onDelete( 58 | null, null, 59 | (item, action) => action.payload.name === item.name || action.payload.color === item.color 60 | ) 61 | }); 62 | const newState = reducer(setUp.state, { type: '@@ACTION/FILTER_DRAGONS', payload: { color: 'black', name: 'Ysera' }, target: 'dragonList' }); 63 | expect(newState.dragonList).toEqual([ 64 | { name: 'Ysera', color: 'green' }, 65 | { name: 'DeathWing', color: 'black' }, 66 | { name: 'Red-Eyes Black Dragon', color: 'black' } 67 | ]); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/effects/onDeleteByIndex/README.md: -------------------------------------------------------------------------------- 1 | ## onDeleteByIndex - Effect 2 | 3 | This effect allow us to delete certain items from arrays according to the index they have. 4 | 5 | These deletions are managed via `action.payload` by default. 6 | 7 | Example: 8 | 9 | We have this state: 10 | ```js 11 | const initialState = { 12 | objectList: [{ id: 1 }, { id: 2 }, { id: 3 }] 13 | }; 14 | ``` 15 | 16 | And we want to delete objects _by index_. Then, we'd like to write: 17 | 18 | ```js 19 | dispatch({ type: actions.DELETE_ITEM, payload: 2 }); 20 | ``` 21 | 22 | if we would like to delete the item with `{ id: 3 }`, leading to: 23 | 24 | `objectList: [{ id: 1 }, { id: 2 }]` 25 | 26 | To achieve that, we write this as a _reducer_: 27 | ```js 28 | const reducerDescription = { 29 | [actions.DELETE_ITEM]: onDeleteByIndex() 30 | }; 31 | 32 | export default createReducer(initialState, reducerDescription); 33 | ``` 34 | 35 | ### Custom selectors 36 | 37 | `onDeleteByIndex` effect takes an optional parameter: 38 | * selector: Specifies how we deduce the index from the action and the state. 39 | `(action, state) => number` 40 | By default, is: 41 | `action => action.payload` 42 | -------------------------------------------------------------------------------- /src/effects/onDeleteByIndex/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onDeleteByIndex(selector = action => action.payload) { 4 | return validate({ 5 | name: 'onDeleteByIndex', 6 | realTarget: action => action.target, 7 | do: (action, state) => { 8 | const selectedIndex = selector(action, state); 9 | return selectedIndex === -1 10 | ? state[`${action.target}`] 11 | : [ 12 | ...state[`${action.target}`].slice(0, selector(action, state)), 13 | ...state[`${action.target}`].slice(selector(action, state) + 1) 14 | ]; 15 | } 16 | }); 17 | } 18 | 19 | export default onDeleteByIndex; 20 | -------------------------------------------------------------------------------- /src/effects/onDeleteByIndex/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onDeleteByIndex from '.'; 6 | 7 | const initialState = { 8 | aTarget: [1, 2, 4, 6, 1] 9 | }; 10 | 11 | const setUp = { 12 | state: null 13 | }; 14 | 15 | beforeEach(() => { 16 | setUp.state = Immutable(initialState); 17 | }); 18 | 19 | describe('onDeleteByIndex', () => { 20 | it('By default, deletes reading index from payload', () => { 21 | const reducer = createReducer(setUp.state, { 22 | '@@ACTION/DELETE': onDeleteByIndex() 23 | }); 24 | let newState = reducer(setUp.state, { type: '@@ACTION/DELETE', payload: 0, target: 'aTarget' }); 25 | expect(newState.aTarget).toEqual([2, 4, 6, 1]); 26 | newState = reducer(newState, { type: '@@ACTION/DELETE', payload: 2, target: 'aTarget' }); 27 | expect(newState.aTarget).toEqual([2, 4, 1]); 28 | newState = reducer(newState, { type: '@@ACTION/DELETE', payload: 2, target: 'aTarget' }); 29 | expect(newState.aTarget).toEqual([2, 4]); 30 | }); 31 | it('By default, does not throw on index out of range', () => { 32 | const reducer = createReducer(setUp.state, { 33 | '@@ACTION/DELETE': onDeleteByIndex() 34 | }); 35 | const newState = reducer(setUp.state, { type: '@@ACTION/DELETE', payload: 200, target: 'aTarget' }); 36 | expect(newState.aTarget).toEqual(setUp.state.aTarget); 37 | }); 38 | it('May receive the index via custom payload', () => { 39 | const reducer = createReducer(setUp.state, { 40 | '@@ACTION/DELETE': onDeleteByIndex(action => action.payload.index) 41 | }); 42 | const newState = reducer(setUp.state, { type: '@@ACTION/DELETE', payload: { index: 3 }, target: 'aTarget' }); 43 | expect(newState.aTarget).toEqual([1, 2, 4, 1]); 44 | }); 45 | it('Is secure for -1 value', () => { 46 | const reducer = createReducer(setUp.state, { 47 | '@@ACTION/DELETE': onDeleteByIndex() 48 | }); 49 | // A missing value, wrongCalculatedIndexUseOnDeleteInstead will be -1 50 | const wrongCalculatedIndexUseOnDeleteInstead = setUp.state.aTarget.indexOf(5); 51 | const newState = reducer(setUp.state, { type: '@@ACTION/DELETE', payload: wrongCalculatedIndexUseOnDeleteInstead, target: 'aTarget' }); 52 | expect(newState.aTarget).toEqual(setUp.state.aTarget); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/effects/onFailure/README.md: -------------------------------------------------------------------------------- 1 | ## onFailure - Effect 2 | 3 | This effect is used when describing the `FAILURE` case of the `SUCCESS-FAILURE` pattern. 4 | 5 | This effect is a multi-target effect - It modifies more than one target at the same time. 6 | 7 | It will: 8 | * Put `${action.target}Loading` in `false` 9 | * Put `${action.target}Error` with your `action.payload` by default. 10 | * Put `${action.target}IsRetrying` in `false` if `action.isPolling` is truthy 11 | * Put `${action.target}CountRetry` in `0` if `action.isPolling` is truthy 12 | 13 | Example: 14 | ```js 15 | const reducerDescription = { 16 | [actions.ON_FETCH_FAILURE]: onFailure() 17 | } 18 | ``` 19 | 20 | ### Custom selectors 21 | `onFailure` effect receives an optional parameter: 22 | * selector: This function describes how we read the error from the `action`. 23 | `(action, state) => any` 24 | By default, is: 25 | `action => action.payload` 26 | -------------------------------------------------------------------------------- /src/effects/onFailure/index.js: -------------------------------------------------------------------------------- 1 | import { mergeState } from '../../configuration'; 2 | 3 | // TODO: Add support and validations for multi target actions 4 | function onFailure(selector = action => action.payload) { 5 | return (state, action) => { 6 | const newValues = { 7 | [`${action.target}Error`]: selector(action, state), 8 | [`${action.target}Loading`]: false 9 | }; 10 | if (action.isPolling) { 11 | newValues[`${action.target}IsRetrying`] = false; 12 | newValues[`${action.target}RetryCount`] = 0; 13 | } 14 | 15 | return mergeState(state, newValues); 16 | }; 17 | } 18 | 19 | export default onFailure; 20 | -------------------------------------------------------------------------------- /src/effects/onFailure/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onFailure from '.'; 6 | 7 | const initialState = { 8 | target: 'Some content', 9 | targetLoading: true, 10 | targetError: null 11 | }; 12 | 13 | const initialPollingState = { 14 | target: 'Some content', 15 | targetLoading: true, 16 | targetError: null, 17 | targetIsRetrying: true, 18 | targetRetryCount: 3, 19 | targetTimeoutID: 3 20 | }; 21 | 22 | const setUp = { 23 | state: null, 24 | pollingState: null 25 | }; 26 | 27 | beforeEach(() => { 28 | setUp.state = Immutable(initialState); 29 | setUp.pollingState = Immutable(initialPollingState); 30 | }); 31 | 32 | describe('onFailure', () => { 33 | it('Sets correctly error and loading', () => { 34 | const reducer = createReducer(setUp.state, { 35 | '@@ACTION/TYPE': onFailure() 36 | }); 37 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target', payload: 'Oops !' }); 38 | expect(newState).toEqual({ 39 | target: 'Some content', 40 | targetLoading: false, 41 | targetError: 'Oops !' 42 | }); 43 | }); 44 | it('Sets conditionally error content', () => { 45 | const reducer = createReducer(setUp.state, { 46 | '@@ACTION/TYPE': onFailure(action => `Error: ${action.payload}`) 47 | }); 48 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target', payload: 'Oops !' }); 49 | expect(newState).toEqual({ 50 | target: 'Some content', 51 | targetLoading: false, 52 | targetError: 'Error: Oops !' 53 | }); 54 | }); 55 | it('Sets polling target', () => { 56 | const reducer = createReducer(setUp.pollingState, { 57 | '@@ACTION/TYPE_FAILURE': onFailure() 58 | }); 59 | const newState = reducer(setUp.pollingState, { 60 | type: '@@ACTION/TYPE_FAILURE', 61 | target: 'target', 62 | payload: 'Boom!', 63 | isPolling: true 64 | }); 65 | expect(newState).toEqual({ 66 | target: 'Some content', 67 | targetLoading: false, 68 | targetError: 'Boom!', 69 | targetIsRetrying: false, 70 | targetRetryCount: 0, 71 | targetTimeoutID: 3 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/effects/onLoaded/README.md: -------------------------------------------------------------------------------- 1 | ## onLoaded - Effect 2 | 3 | This effect puts `${action.target}Loading` in false. 4 | 5 | Example of usage: 6 | 7 | ```js 8 | const initialState = { 9 | targetLoading: true 10 | }; 11 | 12 | const reducerDescription = { 13 | [actions.LOADED]: onLoaded() 14 | }; 15 | 16 | export default createReducer(initialState, reducerDescription); 17 | ``` 18 | 19 | ### Custom selectors 20 | `onLoaded` receives an optional parameter: 21 | * selector: Specifies a condition according to the action and the state. The condition result is stored in `${action.target}Loading` 22 | `(action, state) => Boolean` 23 | By default, is: 24 | `() => false` 25 | Which means that it always put `${action.target}Loading` in `false` 26 | -------------------------------------------------------------------------------- /src/effects/onLoaded/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onLoaded(condition = () => false) { 4 | return validate({ 5 | name: 'onLoaded', 6 | realTarget: action => `${action.target}Loading`, 7 | do: condition 8 | }); 9 | } 10 | 11 | export default onLoaded; 12 | -------------------------------------------------------------------------------- /src/effects/onLoaded/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onLoaded from '.'; 6 | import onLoading from '../onLoading'; 7 | 8 | const initialState = { 9 | target: null, 10 | targetLoading: true, 11 | count: 0 12 | }; 13 | 14 | const setUp = { 15 | state: null 16 | }; 17 | 18 | beforeEach(() => { 19 | setUp.state = Immutable(initialState); 20 | }); 21 | 22 | describe('onLoaded', () => { 23 | it('Sets correctly loading target', () => { 24 | const reducer = createReducer(setUp.state, { 25 | '@@ACTION/TYPE_LOADED': onLoaded(), 26 | '@@ACTION/TYPE_LOADING': onLoading() 27 | }); 28 | let newState = reducer(setUp.state, { type: '@@ACTION/TYPE_LOADING', target: 'target' }); 29 | expect(newState.targetLoading).toBe(true); 30 | newState = reducer(setUp.state, { type: '@@ACTION/TYPE_LOADED', target: 'target' }); 31 | expect(newState.targetLoading).toBe(false); 32 | }); 33 | it('Does not modify other targets', () => { 34 | const reducer = createReducer(setUp.state, { 35 | '@@ACTION/TYPE': onLoaded() 36 | }); 37 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target' }); 38 | expect(newState.target).toBeNull(); 39 | }); 40 | it('Sets loading conditionally', () => { 41 | const reducer = createReducer(setUp.state, { 42 | '@@ACTION/TYPE': onLoaded((action, state) => !!action.payload || state.count > 0) 43 | }); 44 | let newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target' }); 45 | expect(newState.targetLoading).toBe(false); 46 | newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target', payload: 'payload' }); 47 | expect(newState.targetLoading).toBe(true); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/effects/onLoading/README.md: -------------------------------------------------------------------------------- 1 | ## onLoading - Effect 2 | 3 | This effect puts `${action.target}Loading` in true. 4 | 5 | Example of usage: 6 | 7 | ```js 8 | const initialState = { 9 | targetLoading: true 10 | }; 11 | 12 | const reducerDescription = { 13 | [actions.LOADING]: onLoading() 14 | }; 15 | 16 | export default createReducer(initialState, reducerDescription); 17 | ``` 18 | 19 | ### Custom selectors 20 | `onLoading` receives an optional parameter: 21 | * selector: Specifies a condition according to the action and the state. The condition result is stored in `${action.target}Loading` 22 | `(action, state) => Boolean` 23 | By default, is: 24 | `() => true` 25 | Which means that it always put `${action.target}Loading` in `true` 26 | -------------------------------------------------------------------------------- /src/effects/onLoading/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onLoading(condition = () => true) { 4 | return validate({ 5 | name: 'onLoading', 6 | realTarget: action => `${action.target}Loading`, 7 | do: condition 8 | }); 9 | } 10 | 11 | export default onLoading; 12 | -------------------------------------------------------------------------------- /src/effects/onLoading/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onLoading from '.'; 6 | 7 | const initialState = { 8 | target: null, 9 | targetLoading: false, 10 | count: 0 11 | }; 12 | 13 | const setUp = { 14 | state: null 15 | }; 16 | 17 | beforeEach(() => { 18 | setUp.state = Immutable(initialState); 19 | }); 20 | 21 | describe('onLoading', () => { 22 | it('Sets correctly loading target', () => { 23 | const reducer = createReducer(setUp.state, { 24 | '@@ACTION/TYPE': onLoading() 25 | }); 26 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target' }); 27 | expect(newState.targetLoading).toBe(true); 28 | }); 29 | it('Does not modify other targets', () => { 30 | const reducer = createReducer(setUp.state, { 31 | '@@ACTION/TYPE': onLoading() 32 | }); 33 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target' }); 34 | expect(newState.target).toBeNull(); 35 | }); 36 | it('Sets loading conditionally', () => { 37 | const reducer = createReducer(setUp.state, { 38 | '@@ACTION/TYPE': onLoading((action, state) => !!action.payload || state.count > 0) 39 | }); 40 | let newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target' }); 41 | expect(newState.targetLoading).toBe(false); 42 | newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target', payload: 'payload' }); 43 | expect(newState.targetLoading).toBe(true); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/effects/onReadValue/README.md: -------------------------------------------------------------------------------- 1 | ## onReadValue - Effect 2 | 3 | This effect allow us to read values from the `action` and to put them directly in the state. 4 | 5 | By default, this effect reads `action.payload`. 6 | 7 | Example: 8 | ```js 9 | const initialState = { 10 | aTarget: null 11 | }; 12 | 13 | const reducerDescription = { 14 | [actions.READ]: onReadValue() 15 | }; 16 | 17 | export default createReducer(initialState, reducerDescription); 18 | ``` 19 | 20 | If we now do: 21 | `dispatch({ 22 | type: actions.READ, 23 | payload: 'Something', 24 | target: 'aTarget' 25 | });` 26 | 27 | Then the state will be like: 28 | ```js 29 | state = { 30 | aTarget: 'Something' 31 | }; 32 | ``` 33 | 34 | ### Custom selectors 35 | `onReadValue` receives an optional parameter. 36 | * selector: It specifies how we are going to read the `action` and the `state` and place the result in `action.target`. 37 | `(action, state) => any` 38 | By default, is: 39 | `action => action.payload` 40 | -------------------------------------------------------------------------------- /src/effects/onReadValue/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onReadValue(selector = action => action.payload) { 4 | return validate({ 5 | name: 'onReadValue', 6 | realTarget: action => action.target, 7 | do: selector 8 | }); 9 | } 10 | 11 | export default onReadValue; 12 | -------------------------------------------------------------------------------- /src/effects/onReadValue/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onReadValue from '.'; 6 | 7 | const initialState = { 8 | aTarget: null, 9 | count: 1 10 | }; 11 | 12 | const setUp = { 13 | state: null 14 | }; 15 | 16 | beforeEach(() => { 17 | setUp.state = Immutable(initialState); 18 | }); 19 | 20 | describe('onReadValue', () => { 21 | it('Reads a value from a payload', () => { 22 | const reducer = createReducer(setUp.state, { 23 | '@@ACTION/TYPE': onReadValue() 24 | }); 25 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', payload: 'An elephant', target: 'aTarget' }); 26 | expect(newState.aTarget).toBe('An elephant'); 27 | }); 28 | 29 | it('Reads a custom value from the payload or the state', () => { 30 | const reducer = createReducer(setUp.state, { 31 | '@@ACTION/ELEPHUN': onReadValue(action => action.payload.elephantCount), 32 | '@@ACTION/ELECOUNT': onReadValue((action, state) => action.payload.elephantCount + state.count) 33 | }); 34 | let newState = reducer(setUp.state, { type: '@@ACTION/ELEPHUN', payload: { elephantCount: 3 }, target: 'aTarget' }); 35 | expect(newState.aTarget).toBe(3); 36 | newState = reducer(newState, { type: '@@ACTION/ELECOUNT', payload: { elephantCount: 5 }, target: 'count' }); 37 | expect(newState.count).toBe(6); 38 | expect(newState.aTarget).toBe(3); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/effects/onReplace/README.md: -------------------------------------------------------------------------------- 1 | ## onReplace - Effect 2 | 3 | This effect allows modifying an element of an array. Receive a function that tells how to find the element (s) or receives the element's index. 4 | If you pass a function, change all the elements that meet the condition. 5 | 6 | Example: 7 | 8 | ```js 9 | const initialState = { 10 | letterArray: ['H','I','J','K','L','M','N'] 11 | }; 12 | 13 | const reducerDescription = { 14 | [actions.REPLACE]: onReplace() 15 | }; 16 | 17 | export default createReducer(initialState, reducerDescription); 18 | ``` 19 | 20 | If we now do: 21 | `dispatch({ type: actions.REPLACE, target: 'letterArray', index: 3, payload: 'Z' });` 22 | 23 | Then the state will be: 24 | 25 | ```js 26 | state = { 27 | letterArray: ['H','I','J','Z','L','M','N'] 28 | }; 29 | ``` 30 | Example 2: 31 | 32 | ```js 33 | const initialState = { 34 | numberArray: [23, 45, 56, 12, 28, 45, 90, 36, 44, 67] 35 | }; 36 | 37 | const reducerDescription = { 38 | [actions.REPLACE]: onReplace() 39 | }; 40 | 41 | export default createReducer(initialState, reducerDescription); 42 | ``` 43 | 44 | If we now do: 45 | `dispatch({ type: actions.REPLACE, target: 'numberArray', condition: element => element > 50, payload: 51 });` 46 | 47 | Then the state will be: 48 | 49 | ```js 50 | state = { 51 | numberArray: [23, 45, 51, 12, 28, 45, 51, 36, 44, 51] 52 | }; 53 | ``` 54 | -------------------------------------------------------------------------------- /src/effects/onReplace/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onReplace() { 4 | return validate({ 5 | name: 'onReplace', 6 | realTarget: action => action.target, 7 | do: (action, state) => { 8 | const { 9 | payload, index, condition, target 10 | } = action; 11 | const array = [...state[target]]; 12 | if (condition) return array.map(element => (condition(element) ? payload : element)); 13 | if (index) array[index] = payload; 14 | return [...array]; 15 | } 16 | }); 17 | } 18 | 19 | export default onReplace; 20 | -------------------------------------------------------------------------------- /src/effects/onReplace/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onReplace from '.'; 6 | 7 | const initialState = { 8 | letterArray: ['H', 'I', 'J', 'K', 'L', 'M', 'N'], 9 | numberArray: [23, 45, 56, 12, 28, 45, 90, 36, 44, 67] 10 | }; 11 | 12 | const setUp = { 13 | state: null 14 | }; 15 | 16 | beforeEach(() => { 17 | setUp.state = Immutable(initialState); 18 | }); 19 | 20 | describe('onReplace', () => { 21 | it('Replace position on array by index', () => { 22 | const reducer = createReducer(setUp.state, { 23 | '@@ACTION/REPLACE': onReplace() 24 | }); 25 | const newState = reducer(setUp.state, { 26 | type: '@@ACTION/REPLACE', 27 | target: 'letterArray', 28 | index: 3, 29 | payload: 'Z' 30 | }); 31 | expect(newState.letterArray).toEqual(['H', 'I', 'J', 'Z', 'L', 'M', 'N']); 32 | }); 33 | 34 | it('Replace position on array by condition', () => { 35 | const reducer = createReducer(setUp.state, { 36 | '@@ACTION/REPLACE': onReplace() 37 | }); 38 | const newState = reducer(setUp.state, { 39 | type: '@@ACTION/REPLACE', 40 | target: 'numberArray', 41 | condition: element => element > 50, 42 | payload: 51 43 | }); 44 | expect(newState.numberArray).toEqual([23, 45, 51, 12, 28, 45, 51, 36, 44, 51]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/effects/onRetry/README.md: -------------------------------------------------------------------------------- 1 | ## onRetry - Effect 2 | 3 | This effect is used when the `shouldRetry` function of a polling action returns a truthy value. It sets up the state with the info related to the next try. 4 | 5 | This effect is a multi-target effect - It modifies more than one target at the same time. 6 | 7 | It will: 8 | 9 | - Set `${action.target}IsRetrying` as `true` 10 | - Set `${action.target}Loading` as `false` 11 | - Increment `${action.target}RetryCount` by 1 12 | - Set `${action.target}Error` as `action.payload.error` by default. 13 | - Set `${action.target}` as `action.payload.interval` 14 | 15 | Example: 16 | 17 | ```js 18 | const selector = 19 | (action, state) => action.payload.customError || state.defaultError; 20 | 21 | const reducerDescription = { 22 | 'RETRY': onRetry(), 23 | 'RETRY_CUSTOM': onRetry(selector) 24 | }; 25 | ``` 26 | 27 | ### Custom selectors 28 | 29 | `onRetry` effect receives an optional parameter: 30 | 31 | - selector: This function describes how we read the data from the `action`. 32 | `(action, state) => any` 33 | By default, is: 34 | `action => action.payload.error` 35 | -------------------------------------------------------------------------------- /src/effects/onRetry/index.js: -------------------------------------------------------------------------------- 1 | import { mergeState } from '../../configuration'; 2 | 3 | function onRetry(selector = action => action.payload.error) { 4 | return (state, action) => mergeState(state, { 5 | [`${action.target}IsRetrying`]: true, 6 | [`${action.target}Loading`]: false, 7 | [`${action.target}RetryCount`]: state[`${action.target}RetryCount`] + 1, 8 | [`${action.target}Error`]: selector(action, state), 9 | [`${action.target}TimeoutID`]: action.payload.timeoutID 10 | }); 11 | } 12 | 13 | export default onRetry; 14 | -------------------------------------------------------------------------------- /src/effects/onRetry/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onRetry from '.'; 6 | 7 | const initialState = { 8 | target: null, 9 | targetLoading: true, 10 | targetError: null, 11 | targetRetryCount: 0, 12 | targetTimeoutID: null, 13 | targetIsRetrying: false 14 | }; 15 | 16 | const setUp = { 17 | state: null 18 | }; 19 | 20 | beforeEach(() => { 21 | setUp.state = Immutable(initialState); 22 | }); 23 | 24 | describe('onRetry', () => { 25 | it('Sets correctly target with error and loading', () => { 26 | const reducer = createReducer(setUp.state, { 27 | '@@ACTION/TYPE': onRetry() 28 | }); 29 | const newState = reducer(setUp.state, { 30 | type: '@@ACTION/TYPE', 31 | target: 'target', 32 | payload: { error: 'Please try again', timeoutID: 1 } 33 | }); 34 | expect(newState).toEqual({ 35 | target: null, 36 | targetLoading: false, 37 | targetError: 'Please try again', 38 | targetRetryCount: 1, 39 | targetTimeoutID: 1, 40 | targetIsRetrying: true 41 | }); 42 | }); 43 | it('Sets conditionally target content based on payload', () => { 44 | const reducer = createReducer(setUp.state, { 45 | '@@ACTION/TYPE': onRetry(action => action.payload.customError) 46 | }); 47 | const newState = reducer(setUp.state, { 48 | type: '@@ACTION/TYPE', 49 | target: 'target', 50 | payload: { customError: 'Please try again', timeoutID: 1 } 51 | }); 52 | expect(newState).toEqual({ 53 | target: null, 54 | targetLoading: false, 55 | targetError: 'Please try again', 56 | targetRetryCount: 1, 57 | targetTimeoutID: 1, 58 | targetIsRetrying: true 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/effects/onSetValue/README.md: -------------------------------------------------------------------------------- 1 | ## onSetValue - Effect 2 | 3 | This effect allow us to put a constant in the state. 4 | 5 | Example: 6 | ```js 7 | const initialState = { 8 | aTarget: null 9 | }; 10 | 11 | const reducerDescription = { 12 | [actions.LOADING]: onSetValue(true) 13 | }; 14 | 15 | export default createReducer(initialState, reducerDescription); 16 | ``` 17 | 18 | If we now do: 19 | `dispatch({ type: actions.LOADING, target: 'aTarget' });` 20 | 21 | Then the state will be like: 22 | ```js 23 | state = { 24 | aTarget: true 25 | }; 26 | ``` 27 | -------------------------------------------------------------------------------- /src/effects/onSetValue/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function setValue(value) { 4 | return validate({ 5 | name: 'onSetValue', 6 | realTarget: action => action.target, 7 | do: () => value 8 | }); 9 | } 10 | 11 | export default setValue; 12 | -------------------------------------------------------------------------------- /src/effects/onSetValue/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onSetValue from '.'; 6 | 7 | const initialState = { 8 | aTarget: null 9 | }; 10 | 11 | const setUp = { 12 | state: null 13 | }; 14 | 15 | beforeEach(() => { 16 | setUp.state = Immutable(initialState); 17 | }); 18 | 19 | describe('onSetValue', () => { 20 | it('Sets a value from the selector', () => { 21 | const reducer = createReducer(setUp.state, { 22 | '@@ACTION/TYPE': onSetValue(42) 23 | }); 24 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'aTarget' }); 25 | expect(newState.aTarget).toBe(42); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/effects/onSpreadValue/README.md: -------------------------------------------------------------------------------- 1 | ## onSpreadValue - Effect 2 | 3 | This effect allows us to read the entries of an object from the `action` and spread them into the state. 4 | 5 | Example: 6 | 7 | ```js 8 | const initialState = { 9 | key1: 10, 10 | key2: 20, 11 | key3: 30 12 | }; 13 | 14 | const reducerDescription = { 15 | [actions.SPREAD]: onSpreadValue() 16 | }; 17 | 18 | export default createReducer(initialState, reducerDescription); 19 | ``` 20 | 21 | If we now do: 22 | `dispatch({ type: actions.SPREAD, payload: { key1: 'Hello', key2: 45 } });` 23 | 24 | Then the state will be: 25 | 26 | ```js 27 | state = { 28 | key1: 'Hello', 29 | key2: 45, 30 | key3: 20 31 | }; 32 | ``` 33 | 34 | ### Custom selectors 35 | 36 | `onSpreadValue` receives an optional parameter: 37 | 38 | - selector: This function describes how we read the object we want to spread from the `action`. 39 | `(action, state) => Object` 40 | By default, is: 41 | `action => action.payload` 42 | -------------------------------------------------------------------------------- /src/effects/onSpreadValue/index.js: -------------------------------------------------------------------------------- 1 | import { mergeState } from '../../configuration'; 2 | 3 | function onSpreadValue(selector = action => action.payload) { 4 | return (state, action) => mergeState(state, selector(action, state)); 5 | } 6 | 7 | export default onSpreadValue; 8 | -------------------------------------------------------------------------------- /src/effects/onSpreadValue/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onSpreadValue from '.'; 6 | 7 | const initialState = { 8 | key1: null, 9 | key2: null 10 | }; 11 | 12 | const setUp = { 13 | state: null 14 | }; 15 | 16 | beforeEach(() => { 17 | setUp.state = Immutable(initialState); 18 | }); 19 | 20 | describe('onSpreadValue', () => { 21 | it('Spread the payload in the state', () => { 22 | const payload = { key1: '2', key2: 42 }; 23 | const reducer = createReducer(setUp.state, { 24 | '@@ACTION/TYPE': onSpreadValue() 25 | }); 26 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', payload }); 27 | Object.keys(payload).forEach(key => { 28 | expect(newState[key]).toBe(payload[key]); 29 | }); 30 | }); 31 | 32 | it('Spread the payload in the state using selector', () => { 33 | const payload = { key1: '2', key2: 42 }; 34 | const reducer = createReducer(setUp.state, { 35 | '@@ACTION/TYPE': onSpreadValue(action => action.data) 36 | }); 37 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', data: payload }); 38 | Object.keys(payload).forEach(key => { 39 | expect(newState[key]).toBe(payload[key]); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/effects/onSuccess/README.md: -------------------------------------------------------------------------------- 1 | ## onSuccess - Effect 2 | 3 | This effect is used when describing the `SUCCESS` case of the `SUCCESS-FAILURE` pattern. 4 | 5 | This effect is a multi-target effect - It modifies more than one target at the same time. 6 | 7 | It will: 8 | * Put `${action.target}Loading` in `false` 9 | * Put `${action.target}Error` in `null` 10 | * Fill `${action.target}` with your `action.payload` by default, or use a selector provided 11 | * Put `${action.target}IsRetrying` in `false` if `action.isPolling` is truthy 12 | * Put `${action.target}CountRetry` in `0` if `action.isPolling` is truthy 13 | 14 | Example: 15 | ```js 16 | const selector = 17 | (action, state) => action.payload || state[action.target]; 18 | 19 | const reducerDescription = { 20 | 'SUCCESS': onSuccess(), 21 | 'SUCCESS_CUSTOM': onSuccess(selector) 22 | }; 23 | ``` 24 | 25 | ### Custom selectors 26 | `onSuccess` effect receives an optional parameter: 27 | * selector: This function describes how we read the data from the `action`. 28 | `(action, state) => any` 29 | By default, is: 30 | `action => action.payload` 31 | -------------------------------------------------------------------------------- /src/effects/onSuccess/index.js: -------------------------------------------------------------------------------- 1 | import { mergeState } from '../../configuration'; 2 | 3 | // TODO: Add support and validations for multi target actions 4 | function onSuccess(selector = action => action.payload) { 5 | return (state, action) => { 6 | const newValues = { 7 | [`${action.target}Loading`]: false, 8 | [`${action.target}`]: selector(action, state), 9 | [`${action.target}Error`]: null 10 | }; 11 | if (action.isPolling) { 12 | newValues[`${action.target}IsRetrying`] = false; 13 | newValues[`${action.target}RetryCount`] = 0; 14 | } 15 | 16 | return mergeState(state, newValues); 17 | }; 18 | } 19 | 20 | export default onSuccess; 21 | -------------------------------------------------------------------------------- /src/effects/onSuccess/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onSuccess from '.'; 6 | 7 | const initialState = { 8 | target: null, 9 | targetLoading: true, 10 | targetError: 'Some error' 11 | }; 12 | 13 | const initialPollingState = { 14 | target: null, 15 | targetLoading: true, 16 | targetError: 'Some error', 17 | targetIsRetrying: true, 18 | targetRetryCount: 3, 19 | targetTimeoutID: 3 20 | }; 21 | 22 | const setUp = { 23 | state: null, 24 | pollingState: null 25 | }; 26 | 27 | beforeEach(() => { 28 | setUp.state = Immutable(initialState); 29 | setUp.pollingState = Immutable(initialPollingState); 30 | }); 31 | 32 | describe('onSuccess', () => { 33 | it('Sets correctly target with error and loading', () => { 34 | const reducer = createReducer(setUp.state, { 35 | '@@ACTION/TYPE': onSuccess() 36 | }); 37 | const newState = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target', payload: 'Success Payload' }); 38 | expect(newState).toEqual({ 39 | target: 'Success Payload', 40 | targetLoading: false, 41 | targetError: null 42 | }); 43 | }); 44 | it('Sets conditionally target content based on payload', () => { 45 | const reducer = createReducer(setUp.state, { 46 | '@@ACTION/TYPE': onSuccess((action, state) => action.payload + (state[action.target] || 0)) 47 | }); 48 | const incrementAction = { type: '@@ACTION/TYPE', target: 'target', payload: 1 }; 49 | setUp.state = reducer(setUp.state, incrementAction); 50 | setUp.state = reducer(setUp.state, incrementAction); 51 | expect(setUp.state).toEqual({ 52 | target: 2, 53 | targetLoading: false, 54 | targetError: null 55 | }); 56 | }); 57 | it('Sets polling target', () => { 58 | const reducer = createReducer(setUp.pollingState, { 59 | '@@ACTION/TYPE_SUCCESS': onSuccess() 60 | }); 61 | const newState = reducer(setUp.pollingState, { 62 | type: '@@ACTION/TYPE_SUCCESS', 63 | target: 'target', 64 | payload: 'Success Payload', 65 | isPolling: true 66 | }); 67 | expect(newState).toEqual({ 68 | target: 'Success Payload', 69 | targetLoading: false, 70 | targetError: null, 71 | targetIsRetrying: false, 72 | targetRetryCount: 0, 73 | targetTimeoutID: 3 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/effects/onToggle/README.md: -------------------------------------------------------------------------------- 1 | ## onToggle - Effect 2 | 3 | This effect allow us to toggle the value of a boolean in the state. 4 | 5 | Example: 6 | 7 | ```js 8 | const initialState = { 9 | isComplete: false 10 | }; 11 | 12 | const reducerDescription = { 13 | [actions.TOGGLE]: onToggle() 14 | }; 15 | 16 | export default createReducer(initialState, reducerDescription); 17 | ``` 18 | 19 | If we now do: 20 | 21 | ```js 22 | dispatch({ type: actions.TOGGLE, target: 'isComplete' }); 23 | ``` 24 | 25 | Then the state will be: 26 | 27 | ```js 28 | state = { 29 | isComplete: true 30 | }; 31 | ``` 32 | 33 | It's also possible to set a custom value directly with this action. This is useful for cases in which you are using the state to determine the visibility of an UI element, like showing an error message that the user can hide. You want to toggle the value from `false` to `true` when the error happens and from `true` to `false` when the user hides the error message. Finally, when the user navigates to a different screen, you may want to set the value to `false` to make sure it's not shown on the new screen. 34 | 35 | ```js 36 | dispatch({ type: actions.TOGGLE, target: 'isComplete', payload: false }); 37 | ``` 38 | 39 | ### Custom selectors 40 | 41 | `onToggle` effect receives an optional parameter: 42 | 43 | - selector: This function describes how we read the custom value from the `action`. 44 | `(action, state) => any` 45 | By default, is: 46 | `action => action.payload` 47 | -------------------------------------------------------------------------------- /src/effects/onToggle/index.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate'; 2 | 3 | function onToggle(selector = action => action.payload) { 4 | return validate({ 5 | name: 'onToggle', 6 | realTarget: action => action.target, 7 | do: (action, state) => (selector(action, state) === undefined ? !state[action.target] : selector(action, state)) 8 | }); 9 | } 10 | 11 | export default onToggle; 12 | -------------------------------------------------------------------------------- /src/effects/onToggle/test.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import onToggle from '.'; 6 | 7 | const initialState = { 8 | target: false 9 | }; 10 | 11 | const setUp = { 12 | state: null 13 | }; 14 | 15 | beforeEach(() => { 16 | setUp.state = Immutable(initialState); 17 | }); 18 | 19 | describe('onToggle', () => { 20 | it('Toggles target value based on state value', () => { 21 | const reducer = createReducer(setUp.state, { 22 | '@@ACTION/TYPE': onToggle() 23 | }); 24 | const toggleAction = { type: '@@ACTION/TYPE', target: 'target' }; 25 | setUp.state = reducer(setUp.state, toggleAction); 26 | expect(setUp.state).toEqual({ 27 | target: true 28 | }); 29 | 30 | setUp.state = reducer(setUp.state, toggleAction); 31 | expect(setUp.state).toEqual({ 32 | target: false 33 | }); 34 | }); 35 | 36 | it('Sets target value based on payload', () => { 37 | const reducer = createReducer(setUp.state, { 38 | '@@ACTION/TYPE': onToggle() 39 | }); 40 | setUp.state = reducer(setUp.state, { type: '@@ACTION/TYPE', target: 'target', payload: false }); 41 | expect(setUp.state).toEqual({ 42 | target: false 43 | }); 44 | }); 45 | 46 | it('Sets target value based on payload with custom selector', () => { 47 | const reducer = createReducer(setUp.state, { 48 | '@@ACTION/TYPE': onToggle(action => action.payload.a.b) 49 | }); 50 | setUp.state = reducer(setUp.state, { 51 | type: '@@ACTION/TYPE', 52 | target: 'target', 53 | payload: { a: { b: false } } 54 | }); 55 | expect(setUp.state).toEqual({ 56 | target: false 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/effects/validate.js: -------------------------------------------------------------------------------- 1 | import { mergeState } from '../configuration'; 2 | 3 | // Common validator for single target effects 4 | // Effects using this validator should be wrapped into an object with the shape: 5 | // { 6 | // realTarget: the target being modified. Notice that it may be different of action.target, 7 | // do: the effect itself 8 | // } 9 | function validateEffect(effect) { 10 | return (state, action) => { 11 | if (!action.target) { 12 | console.warn(`There is no target specified for ${effect.name}.`); 13 | } 14 | if (state[effect.realTarget(action)] === undefined) { 15 | // TODO: RESTORE THIS WARNING 16 | // console.warn(`Missing field declaration for ${effect.realTarget(action)}.`); 17 | } 18 | return mergeState(state, { [effect.realTarget(action)]: effect.do(action, state) }); 19 | }; 20 | } 21 | 22 | export default validateEffect; 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import configureMergeState from './configuration'; 2 | 3 | import wrapCombineReducers, { createExternalActions } from './invisible/wrapCombineReducers'; 4 | import wrapService from './invisible/wrapService'; 5 | 6 | import completeReducer from './completers/completeReducer'; 7 | import completeState from './completers/completeState'; 8 | import completeTypes from './completers/completeTypes'; 9 | 10 | import createReducer from './creators/createReducer'; 11 | import createTypes from './creators/createTypes'; 12 | import createThunkAction from './creators/createThunkAction'; 13 | 14 | import onDelete from './effects/onDelete'; 15 | import onDeleteByIndex from './effects/onDeleteByIndex'; 16 | import onFailure from './effects/onFailure'; 17 | import onLoaded from './effects/onLoaded'; 18 | import onLoading from './effects/onLoading'; 19 | import onReadValue from './effects/onReadValue'; 20 | import onSetValue from './effects/onSetValue'; 21 | import onSpreadValue from './effects/onSpreadValue'; 22 | import onSuccess from './effects/onSuccess'; 23 | import onToggle from './effects/onToggle'; 24 | import onAppend from './effects/onAppend'; 25 | import onCycle from './effects/onCycle'; 26 | import onConcatenate from './effects/onConcatenate'; 27 | import onReplace from './effects/onReplace'; 28 | import onRetry from './effects/onRetry'; 29 | import onCancel from './effects/onCancel'; 30 | 31 | import baseThunkAction from './injections/baseThunkAction'; 32 | import composeInjections from './injections/composeInjections'; 33 | import withFailure from './injections/withFailure'; 34 | import withFlowDetermination from './injections/withFlowDetermination'; 35 | import withPostFetch from './injections/withPostFetch'; 36 | import withPostSuccess from './injections/withPostSuccess'; 37 | import withPostFailure from './injections/withPostFailure'; 38 | import withPreFetch from './injections/withPreFetch'; 39 | import withStatusHandling from './injections/withStatusHandling'; 40 | import withSuccess from './injections/withSuccess'; 41 | 42 | import fetchMiddleware from './middlewares/fetch'; 43 | 44 | exports.configureMergeState = configureMergeState; 45 | 46 | exports.wrapCombineReducers = wrapCombineReducers; 47 | exports.createExternalActions = createExternalActions; 48 | exports.wrapService = wrapService; 49 | 50 | exports.completeReducer = completeReducer; 51 | exports.completeState = completeState; 52 | exports.completeTypes = completeTypes; 53 | 54 | exports.createReducer = createReducer; 55 | exports.createTypes = createTypes; 56 | exports.createThunkAction = createThunkAction; 57 | 58 | exports.onDelete = onDelete; 59 | exports.onDeleteByIndex = onDeleteByIndex; 60 | exports.onFailure = onFailure; 61 | exports.onLoaded = onLoaded; 62 | exports.onLoading = onLoading; 63 | exports.onReadValue = onReadValue; 64 | exports.onSetValue = onSetValue; 65 | exports.onSpreadValue = onSpreadValue; 66 | exports.onSuccess = onSuccess; 67 | exports.onToggle = onToggle; 68 | exports.onAppend = onAppend; 69 | exports.onCycle = onCycle; 70 | exports.onConcatenate = onConcatenate; 71 | exports.onReplace = onReplace; 72 | exports.onRetry = onRetry; 73 | exports.onCancel = onCancel; 74 | 75 | exports.baseThunkAction = baseThunkAction; 76 | exports.composeInjections = composeInjections; 77 | exports.withFailure = withFailure; 78 | exports.withFlowDetermination = withFlowDetermination; 79 | exports.withPostFetch = withPostFetch; 80 | exports.withPostSuccess = withPostSuccess; 81 | exports.withPostFailure = withPostFailure; 82 | exports.withPreFetch = withPreFetch; 83 | exports.withStatusHandling = withStatusHandling; 84 | exports.withSuccess = withSuccess; 85 | 86 | exports.fetchMiddleware = fetchMiddleware; 87 | -------------------------------------------------------------------------------- /src/injections/baseThunkAction/index.js: -------------------------------------------------------------------------------- 1 | function baseThunkAction({ 2 | type, 3 | target, 4 | service, 5 | payload = () => {}, 6 | successSelector = response => response.data, 7 | failureSelector = response => response.problem 8 | }) { 9 | const selector = typeof payload === 'function' ? payload : () => payload; 10 | 11 | return { 12 | prebehavior: dispatch => dispatch({ type, target }), 13 | apiCall: async getState => service(selector(getState())), 14 | determination: response => response.ok, 15 | success: (dispatch, response) => dispatch({ type: `${type}_SUCCESS`, target, payload: successSelector(response) }), 16 | failure: (dispatch, response) => dispatch({ type: `${type}_FAILURE`, target, payload: failureSelector(response) }) 17 | }; 18 | } 19 | 20 | export default baseThunkAction; 21 | -------------------------------------------------------------------------------- /src/injections/baseThunkAction/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | const MockService = { 5 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 30 })), 6 | fetchSomethingForSelector: () => new Promise(resolve => resolve({ ok: true, newData: 40 })), 7 | fetchFailure: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR' })), 8 | fetchFailureForSelector: () => new Promise(resolve => resolve({ ok: false, error: 'NEW_CLIENT_ERROR' })) 9 | }; 10 | 11 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'OTHER_FETCH'], '@TEST'); 12 | 13 | describe('baseThunkAction', () => { 14 | it('Uses the default success selector if not specified via parameters', async () => { 15 | const store = mockStore({}); 16 | await store.dispatch({ 17 | type: actions.FETCH, 18 | target: 'aTarget', 19 | service: MockService.fetchSomething 20 | }); 21 | const actionsDispatched = store.getActions(); 22 | expect(actionsDispatched).toEqual([ 23 | { type: actions.FETCH, target: 'aTarget' }, 24 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 30 } 25 | ]); 26 | }); 27 | it('Uses success selector specified via parameters', async () => { 28 | const store = mockStore({}); 29 | await store.dispatch({ 30 | type: actions.FETCH, 31 | target: 'aTarget', 32 | service: MockService.fetchSomethingForSelector, 33 | successSelector: response => response.newData 34 | }); 35 | const actionsDispatched = store.getActions(); 36 | expect(actionsDispatched).toEqual([ 37 | { type: actions.FETCH, target: 'aTarget' }, 38 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 40 } 39 | ]); 40 | }); 41 | 42 | it('Uses the default failure selector if not specified via parameters', async () => { 43 | const store = mockStore({}); 44 | await store.dispatch({ 45 | type: actions.FETCH, 46 | target: 'aTarget', 47 | service: MockService.fetchFailure 48 | }); 49 | const actionsDispatched = store.getActions(); 50 | expect(actionsDispatched).toEqual([ 51 | { type: actions.FETCH, target: 'aTarget' }, 52 | { type: actions.FETCH_FAILURE, target: 'aTarget', payload: 'CLIENT_ERROR' } 53 | ]); 54 | }); 55 | it('Uses success selector specified via parameters', async () => { 56 | const store = mockStore({}); 57 | await store.dispatch({ 58 | type: actions.FETCH, 59 | target: 'aTarget', 60 | service: MockService.fetchFailureForSelector, 61 | failureSelector: response => response.error 62 | }); 63 | const actionsDispatched = store.getActions(); 64 | expect(actionsDispatched).toEqual([ 65 | { type: actions.FETCH, target: 'aTarget' }, 66 | { type: actions.FETCH_FAILURE, target: 'aTarget', payload: 'NEW_CLIENT_ERROR' } 67 | ]); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/injections/composeInjections/index.js: -------------------------------------------------------------------------------- 1 | function composeInjections(injections) { 2 | const { 3 | prebehavior = () => {}, 4 | apiCall = () => {}, 5 | determination = () => true, 6 | success = () => true, 7 | postSuccess = () => {}, 8 | postBehavior = () => {}, 9 | postFailure = () => {}, 10 | failure = () => {}, 11 | statusHandler = () => true 12 | } = injections; 13 | 14 | return async (dispatch, getState) => { 15 | prebehavior(dispatch); 16 | const response = await apiCall(getState); 17 | postBehavior(dispatch, response); 18 | if (determination(response, getState)) { 19 | const shouldContinue = success(dispatch, response, getState); 20 | if (shouldContinue) postSuccess(dispatch, response, getState); 21 | } else { 22 | const shouldContinue = statusHandler(dispatch, response, getState); 23 | if (shouldContinue) { 24 | failure(dispatch, response, getState); 25 | postFailure(dispatch, response, getState); 26 | } 27 | } 28 | }; 29 | } 30 | 31 | export default composeInjections; 32 | -------------------------------------------------------------------------------- /src/injections/constants.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/redux-recompose/a9f5196549f4a07ee5c4c25b25b7a738ed21e0b8/src/injections/constants.js -------------------------------------------------------------------------------- /src/injections/emptyThunkAction/index.js: -------------------------------------------------------------------------------- 1 | function emptyThunkAction({ type, service, payload = () => {} }) { 2 | const selector = typeof payload === 'function' ? payload : () => payload; 3 | 4 | return { 5 | prebehavior: dispatch => dispatch({ type }), 6 | apiCall: async getState => service(selector(getState())), 7 | determination: response => response.ok 8 | }; 9 | } 10 | 11 | export default emptyThunkAction; 12 | -------------------------------------------------------------------------------- /src/injections/emptyThunkAction/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | import withPostSuccess from '../withPostSuccess'; 4 | 5 | const MockService = { 6 | fetchSomething: (data = 42) => new Promise(resolve => resolve({ ok: true, data: data + 1 })), 7 | fetchFailure: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR' })) 8 | }; 9 | 10 | const actions = createTypes(['FETCH', 'OTHER_FETCH'], '@TEST'); 11 | 12 | describe('emptyThunkAction', () => { 13 | it('Just dispatches FETCH action if no target is specified', async () => { 14 | const store = mockStore({}); 15 | await store.dispatch({ type: actions.FETCH, service: MockService.fetchSomething }); 16 | const actionsDispatched = store.getActions(); 17 | expect(actionsDispatched).toEqual([{ type: actions.FETCH }]); 18 | }); 19 | it('Calls the service specified via parameters', async () => { 20 | const store = mockStore({}); 21 | await store.dispatch({ 22 | type: actions.FETCH, 23 | service: MockService.fetchSomething, 24 | payload: 20, 25 | injections: [ 26 | withPostSuccess((dispatch, response) => dispatch({ type: actions.OTHER_FETCH, payload: response.data })) 27 | ] 28 | }); 29 | const actionsDispatched = store.getActions(); 30 | expect(actionsDispatched).toEqual([ 31 | { type: actions.FETCH }, 32 | { type: actions.OTHER_FETCH, payload: 21 } 33 | ]); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/injections/externalBaseAction/index.js: -------------------------------------------------------------------------------- 1 | function externalBaseAction({ 2 | target, 3 | service, 4 | payload = () => {}, 5 | successSelector = response => response.data, 6 | failureSelector = response => response.problem, 7 | external: $ 8 | }) { 9 | const selector = typeof payload === 'function' ? payload : () => payload; 10 | 11 | return { 12 | prebehavior: dispatch => dispatch({ type: $.LOADING, target }), 13 | apiCall: async getState => service(selector(getState())), 14 | determination: response => response.ok, 15 | success: (dispatch, response) => dispatch({ type: $.SUCCESS, target, payload: successSelector(response) }), 16 | failure: (dispatch, response) => dispatch({ type: $.FAILURE, target, payload: failureSelector(response) }) 17 | }; 18 | } 19 | 20 | export default externalBaseAction; 21 | -------------------------------------------------------------------------------- /src/injections/mergeInjections/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const schema = yup.array().typeError('injections should be an array'); 4 | 5 | function mergeInjections(injections) { 6 | schema.validateSync(injections); 7 | return injections.reduce((a, b) => ({ ...a, ...b }), {}); 8 | } 9 | 10 | export default mergeInjections; 11 | -------------------------------------------------------------------------------- /src/injections/mergeInjections/test.js: -------------------------------------------------------------------------------- 1 | import withPreFetch from '../withPreFetch'; 2 | import mergeInjections from './index'; 3 | 4 | describe('mergeInjections', () => { 5 | it('Throws error when injections is not an array', () => { 6 | expect(() => mergeInjections(withPreFetch(() => {}))).toThrow(new TypeError('injections should be an array')); 7 | }); 8 | it('Merges injections', () => { 9 | const mergedInjections = mergeInjections([withPreFetch(() => {})]); 10 | expect(mergedInjections).toEqual({ prebehavior: expect.any(Function) }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/injections/pollingAction/index.js: -------------------------------------------------------------------------------- 1 | function pollingAction(action) { 2 | const { 3 | type, 4 | target, 5 | service, 6 | payload = () => {}, 7 | successSelector = response => response.data, 8 | failureSelector = response => response.problem, 9 | timeout = 5000, 10 | shouldRetry, 11 | determination = response => response.ok 12 | } = action; 13 | const selector = typeof payload === 'function' ? payload : () => payload; 14 | 15 | return { 16 | prebehavior: dispatch => dispatch({ type, target }), 17 | apiCall: async getState => service(selector(getState())), 18 | determination, 19 | success: (dispatch, response) => dispatch({ 20 | type: `${type}_SUCCESS`, 21 | target, 22 | payload: successSelector(response), 23 | isPolling: true 24 | }), 25 | failure: (dispatch, response, getState) => { 26 | if (shouldRetry(response, getState)) { 27 | const timeoutID = setTimeout(() => dispatch(action), timeout); 28 | dispatch({ 29 | type: `${type}_RETRY`, 30 | target, 31 | payload: { timeoutID, error: failureSelector(response) } 32 | }); 33 | } else { 34 | dispatch({ 35 | type: `${type}_FAILURE`, 36 | target, 37 | payload: failureSelector(response), 38 | isPolling: true 39 | }); 40 | } 41 | } 42 | }; 43 | } 44 | 45 | export default pollingAction; 46 | -------------------------------------------------------------------------------- /src/injections/pollingAction/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | let tries = 0; 5 | 6 | jest.useFakeTimers(); 7 | 8 | const MockService = { 9 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 30, newData: 40 })), 10 | fetchFailure: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR' })), 11 | fetchFailureForSelector: () => new Promise(resolve => resolve({ ok: false, error: 'NEW_CLIENT_ERROR' })), 12 | fetchFailureForPolling: () => { 13 | let promise; 14 | if (tries === 2) { 15 | promise = new Promise(resolve => resolve({ ok: true, status: 200, data: 'OK' })); 16 | } else { 17 | promise = new Promise(resolve => resolve({ ok: false, status: 500, problem: 'Still loading' })); 18 | } 19 | tries += 1; 20 | return promise; 21 | } 22 | }; 23 | 24 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'OTHER_FETCH'], '@TEST'); 25 | 26 | describe('pollingAction', () => { 27 | it('Uses the default success selector if not specified via parameters', async () => { 28 | const store = mockStore(); 29 | await store.dispatch({ 30 | type: actions.FETCH, 31 | target: 'aTarget', 32 | service: MockService.fetchSomething, 33 | shouldRetry: () => false 34 | }); 35 | const actionsDispatched = store.getActions(); 36 | expect(actionsDispatched).toEqual([ 37 | { type: actions.FETCH, target: 'aTarget' }, 38 | { 39 | type: actions.FETCH_SUCCESS, 40 | target: 'aTarget', 41 | payload: 30, 42 | isPolling: true 43 | } 44 | ]); 45 | }); 46 | it('Uses success selector specified via parameters', async () => { 47 | const store = mockStore(); 48 | await store.dispatch({ 49 | type: actions.FETCH, 50 | target: 'aTarget', 51 | service: MockService.fetchSomething, 52 | successSelector: response => response.newData, 53 | shouldRetry: () => false 54 | }); 55 | const actionsDispatched = store.getActions(); 56 | expect(actionsDispatched).toEqual([ 57 | { type: actions.FETCH, target: 'aTarget' }, 58 | { 59 | type: actions.FETCH_SUCCESS, 60 | target: 'aTarget', 61 | payload: 40, 62 | isPolling: true 63 | } 64 | ]); 65 | }); 66 | it('Uses the default failure selector if not specified via parameters', async () => { 67 | const store = mockStore(); 68 | await store.dispatch({ 69 | type: actions.FETCH, 70 | target: 'aTarget', 71 | service: MockService.fetchFailure, 72 | shouldRetry: () => false 73 | }); 74 | const actionsDispatched = store.getActions(); 75 | expect(actionsDispatched).toEqual([ 76 | { type: actions.FETCH, target: 'aTarget' }, 77 | { 78 | type: actions.FETCH_FAILURE, 79 | target: 'aTarget', 80 | payload: 'CLIENT_ERROR', 81 | isPolling: true 82 | } 83 | ]); 84 | }); 85 | it('Uses failure selector specified via parameters', async () => { 86 | const store = mockStore(); 87 | await store.dispatch({ 88 | type: actions.FETCH, 89 | target: 'aTarget', 90 | service: MockService.fetchFailureForSelector, 91 | failureSelector: response => response.error, 92 | shouldRetry: () => false 93 | }); 94 | 95 | const actionsDispatched = store.getActions(); 96 | expect(actionsDispatched).toEqual([ 97 | { type: actions.FETCH, target: 'aTarget' }, 98 | { 99 | type: actions.FETCH_FAILURE, 100 | target: 'aTarget', 101 | payload: 'NEW_CLIENT_ERROR', 102 | isPolling: true 103 | } 104 | ]); 105 | }); 106 | it('Retries request', async () => { 107 | const store = mockStore(); 108 | await store.dispatch({ 109 | type: actions.FETCH, 110 | target: 'aTarget', 111 | service: MockService.fetchFailureForPolling, 112 | shouldRetry: response => !response.ok 113 | }); 114 | 115 | expect(setTimeout).toHaveBeenCalledTimes(1); 116 | jest.runAllTimers(); 117 | 118 | // Use setImmediate so the Promise queue moves on and the first setTimeout callback is called 119 | await new Promise(setImmediate); 120 | expect(setTimeout).toHaveBeenCalledTimes(2); 121 | jest.runAllTimers(); 122 | 123 | await new Promise(resolve => setImmediate(resolve)); 124 | 125 | const actionsDispatched = store.getActions(); 126 | expect(actionsDispatched).toEqual([ 127 | { type: actions.FETCH, target: 'aTarget' }, 128 | { 129 | type: '@TEST/FETCH_RETRY', 130 | target: 'aTarget', 131 | payload: { timeoutID: expect.any(Number), error: 'Still loading' } 132 | }, 133 | { type: actions.FETCH, target: 'aTarget' }, 134 | { 135 | type: '@TEST/FETCH_RETRY', 136 | target: 'aTarget', 137 | payload: { timeoutID: expect.any(Number), error: 'Still loading' } 138 | }, 139 | { type: actions.FETCH, target: 'aTarget' }, 140 | { 141 | type: actions.FETCH_SUCCESS, 142 | target: 'aTarget', 143 | payload: 'OK', 144 | isPolling: true 145 | } 146 | ]); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/injections/singleCallThunkAction/index.js: -------------------------------------------------------------------------------- 1 | function singleCallThunkAction({ service, payload = () => {} }) { 2 | const selector = typeof payload === 'function' ? payload : () => payload; 3 | 4 | return { 5 | apiCall: async getState => service(selector(getState())), 6 | determination: response => response.ok 7 | }; 8 | } 9 | 10 | export default singleCallThunkAction; 11 | -------------------------------------------------------------------------------- /src/injections/singleCallThunkAction/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | import withPostSuccess from '../withPostSuccess'; 4 | 5 | const MockService = { 6 | fetchSomething: (data = 42) => new Promise(resolve => resolve({ ok: true, data: data + 1 })), 7 | fetchFailure: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR' })) 8 | }; 9 | 10 | const actions = createTypes(['FETCH', 'OTHER_FETCH'], '@TEST'); 11 | 12 | describe('singleCallThunkAction', () => { 13 | it('Does not dispatch an action by default', async () => { 14 | const store = mockStore({}); 15 | await store.dispatch({ service: MockService.fetchSomething }); 16 | const actionsDispatched = store.getActions(); 17 | expect(actionsDispatched).toEqual([]); 18 | }); 19 | it('Calls the service specified via parameters', async () => { 20 | const store = mockStore({}); 21 | await store.dispatch({ 22 | service: MockService.fetchSomething, 23 | payload: 20, 24 | injections: [ 25 | withPostSuccess((dispatch, response) => dispatch({ type: actions.OTHER_FETCH, payload: response.data })) 26 | ] 27 | }); 28 | const actionsDispatched = store.getActions(); 29 | expect(actionsDispatched).toEqual([{ type: actions.OTHER_FETCH, payload: 21 }]); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/injections/withFailure/README.md: -------------------------------------------------------------------------------- 1 | ## withFailure 2 | 3 | The `withFailure` injector overrides the behaviour of the [`onFailure` effect](../../effects/onFailure/) which means that the default failure action will not be dispatched, nor the store will be affected unless you explicitly do it in the `withFailure` injector. 4 | Please, do keep this in mind for completed actions and reducers, since the `loading` and `error` property of your `target` will not be automatically set. 5 | 6 | Example: 7 | 8 | ```js 9 | import { withFailure } from 'redux-recompose'; 10 | 11 | const actionCreators = { 12 | someAction: data => ({ 13 | type: actionType, 14 | target: someTarget, 15 | service: someService, 16 | payload: data, 17 | injections: [ 18 | withFailure((dispatch, response, getState) => { 19 | /* insert here whatever logic 20 | * you want to override the onFailure 21 | * effect with. You can dispatch actions 22 | * using the 'dispatch' argument and the 'response' 23 | * argument is the response from the service call. 24 | */ 25 | dispatch({ 26 | type: someOtherAction, 27 | target: someTarget, 28 | payload: somePayload 29 | }); 30 | }) 31 | ] 32 | }) 33 | }; 34 | ``` 35 | 36 | Remember that this injector may only be used for overriding `onFailure`, if you wish to inject some logic after a failed service call (and the default `onFailure` behaviour that comes with it), use `withPostFailure` injector. 37 | -------------------------------------------------------------------------------- /src/injections/withFailure/index.js: -------------------------------------------------------------------------------- 1 | function withFailure(withFailureBehavior) { 2 | return { failure: withFailureBehavior }; 3 | } 4 | 5 | export default withFailure; 6 | -------------------------------------------------------------------------------- /src/injections/withFailure/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | import withStatusHandling from '../withStatusHandling'; 4 | 5 | import withFailure from '.'; 6 | 7 | const MockService = { 8 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42 })), 9 | fetchFailureNotFound: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR', status: 404 })) 10 | }; 11 | 12 | const actions = createTypes( 13 | ['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'OTHER_ACTION', 'NOT_FOUND'], 14 | '@TEST' 15 | ); 16 | 17 | describe('withFailure', () => { 18 | it('Handles correctly failure behavior', async () => { 19 | const store = mockStore({}); 20 | await store.dispatch({ 21 | type: actions.FETCH, 22 | target: 'aTarget', 23 | service: MockService.fetchSomething, 24 | injections: [withFailure(dispatch => dispatch({ type: actions.OTHER_ACTION }))] 25 | }); 26 | await store.dispatch({ 27 | type: actions.FETCH, 28 | target: 'aTarget', 29 | service: MockService.fetchFailureNotFound, 30 | injections: [withFailure(dispatch => dispatch({ type: actions.OTHER_ACTION }))] 31 | }); 32 | const actionsDispatched = store.getActions(); 33 | // Does not dispatch FAILURE action 34 | expect(actionsDispatched).toEqual([ 35 | { type: actions.FETCH, target: 'aTarget' }, 36 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 42 }, 37 | { type: actions.FETCH, target: 'aTarget' }, 38 | { type: actions.OTHER_ACTION } 39 | ]); 40 | }); 41 | it('Does not execute depending on the status handler result', async () => { 42 | const store = mockStore({}); 43 | // Does execute Failure handler 44 | await store.dispatch({ 45 | type: actions.FETCH, 46 | target: 'aTarget', 47 | service: MockService.fetchFailureNotFound, 48 | injections: [ 49 | withStatusHandling({ 404: dispatch => dispatch({ type: actions.NOT_FOUND }) }), 50 | withFailure(dispatch => dispatch({ type: actions.OTHER_ACTION })) 51 | ] 52 | }); 53 | 54 | // Does not execute Failure handler 55 | await store.dispatch({ 56 | type: actions.FETCH, 57 | target: 'aTarget', 58 | service: MockService.fetchFailureNotFound, 59 | injections: [ 60 | withStatusHandling({ 61 | 404: dispatch => { 62 | dispatch({ type: actions.NOT_FOUND }); 63 | return false; 64 | } 65 | }), 66 | withFailure(dispatch => dispatch({ type: actions.OTHER_ACTION })) 67 | ] 68 | }); 69 | 70 | const actionsDispatched = store.getActions(); 71 | // Does not dispatch FAILURE action 72 | expect(actionsDispatched).toEqual([ 73 | { type: actions.FETCH, target: 'aTarget' }, 74 | { type: actions.NOT_FOUND }, 75 | { type: actions.OTHER_ACTION }, 76 | { type: actions.FETCH, target: 'aTarget' }, 77 | { type: actions.NOT_FOUND } 78 | ]); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/injections/withFlowDetermination/README.md: -------------------------------------------------------------------------------- 1 | ## withFlowDetermination 2 | 3 | The `withFlowDetermination` injector allows to have complete control of the `success-failure` pattern. The `withFlowDetermination` function receives the `response` from the service call as parameter and must return `true` if the call response has the conditions to be `successful` or `false` if the conditions make the service call a `failure`. 4 | If `withFlowDetermination` returns `true` the `withSuccess` and `withPostSuccess` injectors will be executed, if defined. 5 | If `withFlowDetermination` returns `false` the `withFailure` and `withPostFailure` injectors will be executed, if defined. 6 | 7 | 8 | Example: 9 | 10 | ```js 11 | import { withFlowDetermination } from 'redux-recompose'; 12 | 13 | const actionCreators = { 14 | someAction: data => ({ 15 | type: actionType, 16 | target: someTarget, 17 | service: someService, 18 | payload: data, 19 | injections: [ 20 | withFlowDetermination(response => response.ok) // this is the default 21 | ] 22 | }) 23 | }; 24 | ``` 25 | -------------------------------------------------------------------------------- /src/injections/withFlowDetermination/index.js: -------------------------------------------------------------------------------- 1 | function withFlowDetermination(determinationBehavior) { 2 | return { 3 | determination: determinationBehavior 4 | }; 5 | } 6 | 7 | export default withFlowDetermination; 8 | -------------------------------------------------------------------------------- /src/injections/withFlowDetermination/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | import withFlowDetermination from '.'; 5 | 6 | const MockService = { 7 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42 })), 8 | fetchFailureNotFound: () => new Promise(resolve => resolve({ 9 | ok: false, 10 | problem: 'CLIENT_ERROR', 11 | status: 404, 12 | data: 39 13 | })) 14 | }; 15 | 16 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'OTHER_ACTION'], '@TEST'); 17 | 18 | describe('withFlowDetermination', () => { 19 | it('Handles correctly the flow determination', async () => { 20 | const store = mockStore({}); 21 | await store.dispatch({ 22 | type: actions.FETCH, 23 | target: 'aTarget', 24 | service: MockService.fetchSomething, 25 | injections: [withFlowDetermination(response => response.ok)] 26 | }); 27 | 28 | await store.dispatch({ 29 | type: actions.FETCH, 30 | target: 'aTarget', 31 | service: MockService.fetchFailureNotFound, 32 | injections: [withFlowDetermination(response => response.ok)] 33 | }); 34 | 35 | await store.dispatch({ 36 | type: actions.FETCH, 37 | target: 'aTarget', 38 | service: MockService.fetchFailureNotFound, 39 | injections: [withFlowDetermination(response => response.status === 404)] 40 | }); 41 | 42 | const actionsDispatched = store.getActions(); 43 | expect(actionsDispatched).toEqual([ 44 | { type: actions.FETCH, target: 'aTarget' }, 45 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 42 }, 46 | { type: actions.FETCH, target: 'aTarget' }, 47 | { type: actions.FETCH_FAILURE, target: 'aTarget', payload: 'CLIENT_ERROR' }, 48 | { type: actions.FETCH, target: 'aTarget' }, 49 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 39 } 50 | ]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/injections/withPostFailure/README.md: -------------------------------------------------------------------------------- 1 | ## withPostFailure 2 | 3 | The `withPostFailure` injector allows to inject behaviour after the service call fails and the `onFailure` effect is executed. This means `${action.target}Loading` and `${action.target}Error` are set before `withPostFailure` is called. 4 | 5 | Example: 6 | 7 | ```js 8 | import { withPostFailure } from 'redux-recompose'; 9 | 10 | const actionCreators = { 11 | someAction: data => ({ 12 | type: actionType, 13 | target: someTarget, 14 | service: someService, 15 | payload: data, 16 | injections: [ 17 | withPostFailure((dispatch, response, getState) => { 18 | /* insert here whatever logic 19 | * you want to execute after a failed service call. 20 | * This is particularly userful to dispatch side efects for failed service calls. 21 | */ 22 | dispatch({ 23 | type: someOtherAction, 24 | target: someTarget, 25 | payload: somePayload 26 | }); 27 | }) 28 | ] 29 | }) 30 | }; 31 | ``` 32 | 33 | -------------------------------------------------------------------------------- /src/injections/withPostFailure/index.js: -------------------------------------------------------------------------------- 1 | function withPostFailure(behavior) { 2 | return { 3 | postFailure: behavior 4 | }; 5 | } 6 | 7 | export default withPostFailure; 8 | -------------------------------------------------------------------------------- /src/injections/withPostFailure/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | import withPostFailure from '.'; 5 | 6 | const MockService = { 7 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42 })), 8 | fetchFailureNotFound: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR', status: 404 })) 9 | }; 10 | 11 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'OTHER_ACTION'], '@TEST'); 12 | 13 | describe('withPostFailure', () => { 14 | it('Handles correctly post failure', async () => { 15 | const store = mockStore({}); 16 | await store.dispatch({ 17 | type: actions.FETCH, 18 | target: 'aTarget', 19 | service: MockService.fetchFailureNotFound, 20 | injections: [withPostFailure(dispatch => dispatch({ type: actions.OTHER_ACTION }))] 21 | }); 22 | 23 | const actionsDispatched = store.getActions(); 24 | expect(actionsDispatched).toEqual([ 25 | { type: actions.FETCH, target: 'aTarget' }, 26 | { type: actions.FETCH_FAILURE, target: 'aTarget', payload: 'CLIENT_ERROR' }, 27 | { type: actions.OTHER_ACTION } 28 | ]); 29 | }); 30 | 31 | it('Does not dispatch on post failure in case of success', async () => { 32 | const store = mockStore({}); 33 | await store.dispatch({ 34 | type: actions.FETCH, 35 | target: 'aTarget', 36 | service: MockService.fetchSomething, 37 | injections: [withPostFailure(dispatch => dispatch({ type: actions.OTHER_ACTION }))] 38 | }); 39 | 40 | const actionsDispatched = store.getActions(); 41 | expect(actionsDispatched).toEqual([ 42 | { type: actions.FETCH, target: 'aTarget' }, 43 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 42 } 44 | ]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/injections/withPostFetch/README.md: -------------------------------------------------------------------------------- 1 | ## withPostFetch 2 | 3 | The `withPostFetch` injector allows to inject behaviour after the service call, regardless of the response status. 4 | This injector doesn't override any of the `success-failure` pattern behavior, which means that the `${action.target}Loading` and `${action.target}Error` will still be changed accordingly (unless you set a different behaviour using other injectors). 5 | This injector is not thought for normalizing/denormalizing data. If this is the intention, [successSelector](../../middlewares/) can be used instead. 6 | 7 | Example: 8 | 9 | ```js 10 | import { withPostFetch } from 'redux-recompose'; 11 | 12 | const actionCreators = { 13 | someAction: data => ({ 14 | type: actionType, 15 | target: someTarget, 16 | service: someService, 17 | payload: data, 18 | injections: [ 19 | withPostFetch((dispatch, response) => { 20 | ... 21 | }); 22 | ] 23 | }) 24 | }; 25 | ``` 26 | 27 | -------------------------------------------------------------------------------- /src/injections/withPostFetch/index.js: -------------------------------------------------------------------------------- 1 | function withPostFetch(behavior) { 2 | return { postBehavior: behavior }; 3 | } 4 | 5 | export default withPostFetch; 6 | -------------------------------------------------------------------------------- /src/injections/withPostFetch/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | import withPostFetch from '.'; 5 | 6 | const MockService = { 7 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42, ultraSecretData: 'rho' })), 8 | fetchFailureNotFound: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR', status: 404 })) 9 | }; 10 | 11 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'FETCH_LOADING'], '@TEST'); 12 | 13 | describe('withPostFetch', () => { 14 | it('Handles correctly the post fetch behavior', async () => { 15 | const store = mockStore({}); 16 | await store.dispatch({ 17 | type: actions.FETCH, 18 | target: 'aTarget', 19 | service: MockService.fetchSomething, 20 | injections: [ 21 | withPostFetch((dispatch, response) => dispatch({ type: actions.FETCH_LOADING, payload: response.ultraSecretData })) 22 | ] 23 | }); 24 | const actionsDispatched = store.getActions(); 25 | expect(actionsDispatched).toEqual([ 26 | { type: actions.FETCH, target: 'aTarget' }, 27 | { type: actions.FETCH_LOADING, payload: 'rho' }, 28 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 42 } 29 | ]); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/injections/withPostSuccess/README.md: -------------------------------------------------------------------------------- 1 | ## withPostSuccess 2 | 3 | The `withPostSuccess` injector allows to inject behaviour after the service call is successful and the `onSuccess` effect is executed. This means that the `${action.target}Loading` and `action.payload` will be set in `${action.target}` by the time `withPostSuccess` is called. This is particularly useful when needing to change or add other properties to the store besides `${action.target}`. 4 | 5 | Example: 6 | 7 | ```js 8 | import { withPostSuccess } from 'redux-recompose'; 9 | 10 | const actionCreators = { 11 | someAction: data => ({ 12 | type: actionType, 13 | target: someTarget, 14 | service: someService, 15 | payload: data, 16 | injections: [ 17 | withPostSuccess((dispatch, response, getState) => { 18 | /* insert here whatever logic 19 | * you want to execute after service call and SUCCCESSFUL pattern. 20 | * This is particularly userful to dispatch side efects or actions to different targets 21 | */ 22 | dispatch({ 23 | type: someOtherAction, 24 | target: someTarget, 25 | payload: somePayload 26 | }); 27 | }) 28 | ] 29 | }) 30 | }; 31 | ``` 32 | 33 | -------------------------------------------------------------------------------- /src/injections/withPostSuccess/index.js: -------------------------------------------------------------------------------- 1 | function withPostSuccess(behavior) { 2 | return { 3 | postSuccess: behavior 4 | }; 5 | } 6 | 7 | export default withPostSuccess; 8 | -------------------------------------------------------------------------------- /src/injections/withPostSuccess/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | import withPostSuccess from '.'; 5 | 6 | const MockService = { 7 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42 })), 8 | fetchFailureNotFound: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR', status: 404 })) 9 | }; 10 | 11 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'OTHER_ACTION'], '@TEST'); 12 | 13 | describe('withPostSuccess', () => { 14 | it('Handles correctly post success', async () => { 15 | const store = mockStore({}); 16 | await store.dispatch({ 17 | type: actions.FETCH, 18 | target: 'aTarget', 19 | service: MockService.fetchSomething, 20 | injections: [withPostSuccess(dispatch => dispatch({ type: actions.OTHER_ACTION }))] 21 | }); 22 | 23 | const actionsDispatched = store.getActions(); 24 | expect(actionsDispatched).toEqual([ 25 | { type: actions.FETCH, target: 'aTarget' }, 26 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 42 }, 27 | { type: actions.OTHER_ACTION } 28 | ]); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/injections/withPreFetch/README.md: -------------------------------------------------------------------------------- 1 | ## withPreFetch 2 | 3 | The `withPreFetch` injector allows to inject behaviour before the service call. This injector is not thought for normalizing/denormalizing data. If this is the intention, 4 | use you normalizing/denormalizing function on the `payload` property as shown below: 5 | 6 | Example: 7 | 8 | ```js 9 | import { withPreFetch } from 'redux-recompose'; 10 | const actionCreators = { 11 | someAction: data => ({ 12 | type: actionType, 13 | target: someTarget, 14 | service: someService, 15 | payload: denormalize(data), 16 | injections: [ 17 | withPreFetch(dispatch => { 18 | /* you can dispatch other actions before the service call */ 19 | }) 20 | ] 21 | }) 22 | }; 23 | ``` 24 | -------------------------------------------------------------------------------- /src/injections/withPreFetch/index.js: -------------------------------------------------------------------------------- 1 | function withPreFetch(behavior) { 2 | return { 3 | prebehavior: behavior 4 | }; 5 | } 6 | 7 | export default withPreFetch; 8 | -------------------------------------------------------------------------------- /src/injections/withPreFetch/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | import withPreFetch from '.'; 5 | 6 | const MockService = { 7 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42 })), 8 | fetchFailureNotFound: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR', status: 404 })) 9 | }; 10 | 11 | const actions = createTypes(['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'FETCH_LOADING'], '@TEST'); 12 | 13 | describe('withPreFetch', () => { 14 | it('Handles correctly the prefetch behavior', async () => { 15 | const store = mockStore({}); 16 | await store.dispatch({ 17 | type: actions.FETCH, 18 | target: 'aTarget', 19 | service: MockService.fetchSomething, 20 | injections: [withPreFetch(dispatch => dispatch({ type: actions.FETCH_LOADING }))] 21 | }); 22 | const actionsDispatched = store.getActions(); 23 | expect(actionsDispatched).toEqual([ 24 | { type: actions.FETCH_LOADING }, 25 | { type: actions.FETCH_SUCCESS, target: 'aTarget', payload: 42 } 26 | ]); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/injections/withStatusHandling/README.md: -------------------------------------------------------------------------------- 1 | ## withStatusHandling 2 | 3 | The `withStatusHandling` injector allows to describe some behavior based on the response status code. 4 | `withStatusHandling` receives an object whose property names are the different http status codes (404, 500, etc) and that define the logic to execute for the desired status code. If you want to execute `onFailure` and `withPostFailure` the handler function should return `true`, in the other case it should return `false`. 5 | 6 | Example: 7 | 8 | ```js 9 | import { withStatusHandling } from 'redux-recompose'; 10 | 11 | const actionCreators = { 12 | someAction: data => ({ 13 | type: actionType, 14 | target: someTarget, 15 | service: someService, 16 | payload: data, 17 | injections: [ 18 | withStatusHandling({ 19 | 401: (dispatch, response, getState) => handle401(getState, dispatch, response), 20 | 404: (dispatch, response, getState) => handle404(response, dispatch, getState) 21 | }) 22 | ] 23 | }) 24 | }; 25 | ``` 26 | 27 | -------------------------------------------------------------------------------- /src/injections/withStatusHandling/index.js: -------------------------------------------------------------------------------- 1 | function withStatusHandling(statusHandlerDescription) { 2 | return { 3 | statusHandler: (dispatch, response, state) => ( 4 | statusHandlerDescription[response.status] 5 | ? statusHandlerDescription[response.status](dispatch, response, state) 6 | : () => true) 7 | }; 8 | } 9 | 10 | export default withStatusHandling; 11 | -------------------------------------------------------------------------------- /src/injections/withStatusHandling/test.js: -------------------------------------------------------------------------------- 1 | import mockStore from '../../utils/asyncActionsUtils'; 2 | import createTypes from '../../creators/createTypes'; 3 | 4 | import withStatusHandling from '.'; 5 | 6 | const MockService = { 7 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 42 })), 8 | fetchFailureNotFound: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR', status: 404 })), 9 | fetchFailureExpiredToken: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR', status: 422 })) 10 | }; 11 | 12 | const actions = createTypes( 13 | ['FETCH', 'FETCH_SUCCESS', 'FETCH_FAILURE', 'NOT_FOUND', 'EXPIRED_TOKEN'], 14 | '@TEST' 15 | ); 16 | 17 | const customThunkAction = serviceCall => ({ 18 | type: actions.FETCH, 19 | target: 'aTarget', 20 | service: serviceCall, 21 | injections: [withStatusHandling({ 404: dispatch => dispatch({ type: actions.NOT_FOUND }) })] 22 | }); 23 | 24 | describe('withStatusHandling', () => { 25 | it('Handles correctly status codes', async () => { 26 | const store = mockStore({}); 27 | await store.dispatch(customThunkAction(MockService.fetchFailureNotFound)); 28 | const actionsDispatched = store.getActions(); 29 | expect(actionsDispatched).toEqual([ 30 | { type: actions.FETCH, target: 'aTarget' }, 31 | { type: actions.NOT_FOUND }, 32 | { type: actions.FETCH_FAILURE, target: 'aTarget', payload: 'CLIENT_ERROR' } 33 | ]); 34 | }); 35 | it('If not encounters a status code handler, it dispatches FAILURE', async () => { 36 | const store = mockStore({}); 37 | await store.dispatch(customThunkAction(MockService.fetchFailureExpiredToken)); 38 | const actionsDispatched = store.getActions(); 39 | expect(actionsDispatched).toEqual([ 40 | { type: actions.FETCH, target: 'aTarget' }, 41 | { type: actions.FETCH_FAILURE, target: 'aTarget', payload: 'CLIENT_ERROR' } 42 | ]); 43 | }); 44 | 45 | it('Does not dispatch a FAILURE if a handler returns false', async () => { 46 | const store = mockStore({}); 47 | await store.dispatch({ 48 | type: actions.FETCH, 49 | target: 'aTarget', 50 | service: MockService.fetchFailureExpiredToken, 51 | injections: [withStatusHandling({ 422: () => false })] 52 | }); 53 | const actionsDispatched = store.getActions(); 54 | expect(actionsDispatched).toEqual([{ type: actions.FETCH, target: 'aTarget' }]); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/injections/withSuccess/README.md: -------------------------------------------------------------------------------- 1 | ## withSuccess 2 | 3 | The `withSuccess` injector overrides the behaviour of the [`onSuccess` effect](../../effects/onSuccess/) which means that the default success action will not be dispatched, nor the store will be affected unless you explicitly do it in the `withSuccess` injector. 4 | Please, do keep this in mind for completed actions and reducers, since the `loading` property of your `target` will not be automatically set to `false` 5 | 6 | Example: 7 | 8 | ```js 9 | import { withSuccess } from 'redux-recompose'; 10 | 11 | const actionCreators = { 12 | someAction: data => ({ 13 | type: actionType, 14 | target: someTarget, 15 | service: someService, 16 | payload: data, 17 | injections: [ 18 | withSuccess((dispatch, response, getState) => { 19 | /* insert here whatever logic 20 | * you want to override the onSuccess 21 | * effect with. You can dispatch actions 22 | * using the 'dispatch' argument and the 'response' 23 | * argument is the response from the service call. 24 | */ 25 | dispatch({ 26 | type: someOtherAction, 27 | target: someTarget, 28 | payload: somePayload 29 | }); 30 | }) 31 | ] 32 | }) 33 | }; 34 | ``` 35 | 36 | Remember that this injector may only be used for overriding `onSuccess`, if you wish to inject some logic after a successful service call (and the default `onSuccess` behaviour that comes with it), use `withPostSuccess` injector. 37 | -------------------------------------------------------------------------------- /src/injections/withSuccess/index.js: -------------------------------------------------------------------------------- 1 | function withSuccess(withSuccessBehavior) { 2 | return { success: withSuccessBehavior }; 3 | } 4 | 5 | export default withSuccess; 6 | -------------------------------------------------------------------------------- /src/invisible/wrapCombineReducers/commonReducer.js: -------------------------------------------------------------------------------- 1 | import createReducer from '../../creators/createReducer'; 2 | import onLoading from '../../effects/onLoading'; 3 | import onSuccess from '../../effects/onSuccess'; 4 | import onFailure from '../../effects/onFailure'; 5 | 6 | // TODO: Let the user specify selectors 7 | const reducerDescription = { 8 | LOADING: onLoading(), 9 | SUCCESS: onSuccess(), 10 | FAILURE: onFailure() 11 | }; 12 | 13 | export const defaultActionNames = Object.keys(reducerDescription); 14 | 15 | // TODO: Let user specify this initialState 16 | export default createReducer({}, reducerDescription); 17 | -------------------------------------------------------------------------------- /src/invisible/wrapCombineReducers/index.js: -------------------------------------------------------------------------------- 1 | import createTypes from '../../creators/createTypes'; 2 | 3 | import commonReducer, { defaultActionNames } from './commonReducer'; 4 | 5 | const INVISIBLE_NAMESPACE = '$INVISIBLE'; 6 | 7 | const getSuperNamespace = actionName => actionName.slice(0, actionName.indexOf(':')); 8 | const shouldBeExtended = action => getSuperNamespace(action.type) === INVISIBLE_NAMESPACE; 9 | 10 | const getSliceName = (action, reducerObject) => { 11 | let sliceName = action.type 12 | .slice(action.type.indexOf(':') + 1, action.type.indexOf('/')) 13 | .replace('#', '') 14 | .toLowerCase(); 15 | Object.keys(reducerObject).forEach(reducerName => { 16 | if (reducerName.toLowerCase() === sliceName) sliceName = reducerName; 17 | }); 18 | return sliceName; 19 | }; 20 | 21 | const formatActionName = actionName => actionName.slice(actionName.indexOf('/') + 1); 22 | 23 | function wrapCombineReducers(CR, invisibleReducer = commonReducer) { 24 | function combineReducers(reducerObject) { 25 | return (state = {}, action) => { 26 | if (!shouldBeExtended(action)) return CR(reducerObject)(state, action); 27 | const slice = getSliceName(action, reducerObject); 28 | return { 29 | ...state, 30 | [slice]: invisibleReducer( 31 | state[slice], 32 | { ...action, type: formatActionName(action.type) } 33 | ) 34 | }; 35 | }; 36 | } 37 | return combineReducers; 38 | } 39 | 40 | export function createExternalActions(reducerName, actionNames = defaultActionNames) { 41 | return createTypes(actionNames, `${INVISIBLE_NAMESPACE}:#${reducerName.toUpperCase()}`); 42 | } 43 | 44 | export default wrapCombineReducers; 45 | -------------------------------------------------------------------------------- /src/invisible/wrapCombineReducers/test.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, createStore } from 'redux'; 2 | 3 | import createReducer from '../../creators/createReducer'; 4 | 5 | import wrapCombineReducers, { createExternalActions } from '.'; 6 | 7 | const setUp = { 8 | store: null 9 | }; 10 | 11 | const configureStore = invisibleReducer => { 12 | const reducersObject = { 13 | foo: (state = { NSLoading: false }) => state, 14 | dummy: (state = {}) => state 15 | }; 16 | const ownCombineReducers = wrapCombineReducers(combineReducers, invisibleReducer); 17 | return createStore(ownCombineReducers(reducersObject)); 18 | }; 19 | 20 | beforeEach(() => { 21 | setUp.store = configureStore(); 22 | }); 23 | 24 | describe('wrapCombineReducers', () => { 25 | it('Wrap reducers can combine reducers and provide an invisible reducer', async () => { 26 | const $ = createExternalActions('foo'); 27 | await setUp.store.dispatch({ type: $.LOADING, target: 'NS' }); 28 | expect(setUp.store.getState()).toEqual({ foo: { NSLoading: true }, dummy: {} }); 29 | }); 30 | 31 | it('Allow to customize the invisible reducer', () => { 32 | const invisibleReducer = createReducer( 33 | {}, 34 | { INCREMENT: state => ({ ...state, counter: (state.counter || 0) + 1 }) } 35 | ); 36 | const store = configureStore(invisibleReducer); 37 | const $ = createExternalActions('foo', ['INCREMENT']); 38 | store.dispatch({ type: $.INCREMENT }); 39 | 40 | const $$ = createExternalActions('dummy', ['INCREMENT']); 41 | store.dispatch({ type: $$.INCREMENT }); 42 | 43 | expect(store.getState()).toEqual({ 44 | foo: { NSLoading: false, counter: 1 }, 45 | dummy: { counter: 1 } 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/invisible/wrapService/index.js: -------------------------------------------------------------------------------- 1 | import { createExternalActions } from '../wrapCombineReducers'; 2 | 3 | export default (Service, reducerName, mapTargets = {}) => { 4 | const dispatchableServices = {}; 5 | const $ = createExternalActions(reducerName); 6 | Object.keys(Service).forEach(serviceName => { 7 | dispatchableServices[serviceName] = args => ({ 8 | external: $, 9 | target: mapTargets[serviceName] || serviceName, 10 | service: Service[serviceName], 11 | payload: args, 12 | ...Service[serviceName] 13 | }); 14 | }); 15 | return dispatchableServices; 16 | }; 17 | -------------------------------------------------------------------------------- /src/invisible/wrapService/test.js: -------------------------------------------------------------------------------- 1 | import { 2 | combineReducers, createStore, applyMiddleware, compose 3 | } from 'redux'; 4 | 5 | import { thunk } from '../../utils/asyncActionsUtils'; 6 | import withPostSuccess from '../../injections/withPostSuccess'; 7 | import fetchMiddleware from '../../middlewares/fetch'; 8 | import wrapCombineReducers from '../wrapCombineReducers'; 9 | 10 | import wrapService from '.'; 11 | 12 | const MockService = { 13 | fetchSomething: () => new Promise(resolve => resolve({ ok: true, data: 30 })), 14 | fetchSomethingForSelector: () => new Promise(resolve => resolve({ ok: true, data: 40 })), 15 | fetchFailure: () => new Promise(resolve => resolve({ ok: false, problem: 'CLIENT_ERROR' })), 16 | fetchFailureForSelector: () => new Promise(resolve => resolve({ ok: false, error: 'NEW_CLIENT_ERROR' })) 17 | }; 18 | 19 | const setUp = { 20 | store: null 21 | }; 22 | 23 | const configureStore = invisibleReducer => { 24 | const reducersObject = { 25 | foo: (state = { NSLoading: false }) => state, 26 | dummy: (state = {}) => state 27 | }; 28 | const ownCombineReducers = wrapCombineReducers(combineReducers, invisibleReducer); 29 | return createStore( 30 | ownCombineReducers(reducersObject), 31 | compose(applyMiddleware(thunk, fetchMiddleware)) 32 | ); 33 | }; 34 | 35 | beforeEach(() => { 36 | setUp.store = configureStore(); 37 | }); 38 | 39 | describe('wrapService', () => { 40 | it('It should transform services as actions', async () => { 41 | const ServiceActions = wrapService(MockService, 'foo'); 42 | await setUp.store.dispatch(ServiceActions.fetchSomething()); 43 | expect(setUp.store.getState()).toEqual({ 44 | dummy: {}, 45 | foo: { 46 | NSLoading: false, 47 | fetchSomething: 30, 48 | fetchSomethingLoading: false, 49 | fetchSomethingError: null 50 | } 51 | }); 52 | }); 53 | it('Does allow custom injections', async () => { 54 | MockService.fetchSomethingForSelector.injections = [withPostSuccess((_, response) => expect(response).toEqual({ ok: true, data: 40 }))]; 55 | const ServiceActions = wrapService(MockService, 'foo', { fetchSomethingForSelector: 'fetchSomething' }); 56 | await setUp.store.dispatch(ServiceActions.fetchSomethingForSelector()); 57 | expect(setUp.store.getState()).toEqual({ 58 | dummy: {}, 59 | foo: { 60 | NSLoading: false, 61 | fetchSomething: 40, 62 | fetchSomethingLoading: false, 63 | fetchSomethingError: null 64 | } 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/middlewares/README.md: -------------------------------------------------------------------------------- 1 | ## fetchMiddleware 2 | 3 | This middleware allows us to integrate custom middlewares into 4 | `redux-recompose` actions and reducers. 5 | It works with Redux's `applyMiddleware` nicely, since it is simply passed down as a regular middleware 6 | 7 | Usage example: 8 | 9 | ```js 10 | import { fetchMiddleware } from 'redux-recompose'; 11 | 12 | const store = createStore( 13 | reducers, applyMiddleware(fetchMiddleware)) 14 | ); 15 | ``` 16 | 17 | Then, in your `action.js`, the logic for the code you want your middleware to execute can be added like this: 18 | 19 | ```js 20 | const someActions = { 21 | actionWithMiddleware: () => { 22 | /* 23 | * some more code 24 | */ 25 | return { 26 | type: actionType.ACTION_TYPE, 27 | target: 'some_target', 28 | service: someServiceFunction 29 | payload: serviceFunctionPayload, 30 | successSelector: (response) => { /* some return value */ }, 31 | failureSelector: (response) => { /* some return value */ } 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | where: 38 | 39 | `type` is the action type dispatched. 40 | `target` is the target string. 41 | `service` is the service function called by the middleware (`get`, `post`, `put`, etc) 42 | `payload` is the parameter to pass down to the `service`. If more than one argument is required, use an object in a key/value form. Ex: `payload: {someParam: someParamValue, otherParam: otherParamalue}` 43 | `successSelector` is the function to be executed after a successful service call, recieves the response from the `service`. This can be used to format the response from the api call before a state change e.g. `(response) => response.data` 44 | `failureSelector` is the function to be executed after a failed service call, recieves the response from the `service`. Typically used for getting server error messages to display e.g. `(response) => response.error` 45 | -------------------------------------------------------------------------------- /src/middlewares/fetch.js: -------------------------------------------------------------------------------- 1 | import externalBaseAction from '../injections/externalBaseAction'; 2 | import baseThunkAction from '../injections/baseThunkAction'; 3 | import emptyThunkAction from '../injections/emptyThunkAction'; 4 | import singleCallThunkAction from '../injections/singleCallThunkAction'; 5 | import composeInjections from '../injections/composeInjections'; 6 | import mergeInjections from '../injections/mergeInjections'; 7 | import pollingAction from '../injections/pollingAction'; 8 | 9 | const ensembleInjections = action => { 10 | let base; 11 | if (action.external) { 12 | base = externalBaseAction(action); 13 | } else if (!action.type) { 14 | base = singleCallThunkAction(action); 15 | } else if (action.shouldRetry) { 16 | base = pollingAction(action); 17 | } else { 18 | base = action.target ? baseThunkAction(action) : emptyThunkAction(action); 19 | } 20 | if (!action.injections) return base; 21 | const injections = mergeInjections(action.injections); 22 | 23 | return { ...base, ...injections }; 24 | }; 25 | 26 | const fetchMiddleware = ({ dispatch }) => next => action => (action.service ? dispatch(composeInjections(ensembleInjections(action))) : next(action)); 27 | 28 | export default fetchMiddleware; 29 | -------------------------------------------------------------------------------- /src/utils/asyncActionsUtils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import configureMockStore from 'redux-mock-store'; 3 | 4 | import fetchMiddleware from '../middlewares/fetch'; 5 | 6 | export const thunk = ({ dispatch, getState }) => next => action => ( 7 | typeof action === 'function' 8 | ? action(dispatch, getState) 9 | : next(action) 10 | ); 11 | 12 | const middlewares = [fetchMiddleware, thunk]; 13 | const mockStore = configureMockStore(middlewares); 14 | 15 | export default mockStore; 16 | --------------------------------------------------------------------------------