├── .babelrc ├── .editorconfig ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── package.json ├── src ├── actions.js ├── index.js ├── middleware.js ├── reducer.js ├── selectors.js └── utils.js ├── test ├── actions.test.js ├── index.test.js ├── middleware.test.js ├── reducer.test.js ├── selectors.test.js └── utils.test.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": "last 2 versions" 6 | } 7 | }], 8 | "stage-2" 9 | ], 10 | "plugins": ["transform-flow-strip-types"] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb-base", 5 | "plugin:prettier/recommended", 6 | "plugin:flowtype/recommended" 7 | ], 8 | "plugins": [ 9 | "prettier", 10 | "flowtype", 11 | "flowtype-errors" 12 | ], 13 | "env": { 14 | "jest": true 15 | }, 16 | "rules": { 17 | "flowtype-errors/show-errors": "error" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/dist 3 | .*/coverage 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | dist 5 | *.log 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v6 4 | script: 5 | - npm run lint && npm test -- --coverage 6 | cache: 7 | - yarn 8 | after_success: 9 | - bash <(curl -s https://codecov.io/bash) 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Diego Haz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-saga-thunk 2 | 3 | [![Generated with nod](https://img.shields.io/badge/generator-nod-2196F3.svg?style=flat-square)](https://github.com/diegohaz/nod) 4 | [![NPM version](https://img.shields.io/npm/v/redux-saga-thunk.svg?style=flat-square)](https://npmjs.org/package/redux-saga-thunk) 5 | [![NPM downloads](https://img.shields.io/npm/dm/redux-saga-thunk.svg?style=flat-square)](https://npmjs.org/package/redux-saga-thunk) 6 | [![Build Status](https://img.shields.io/travis/diegohaz/redux-saga-thunk/master.svg?style=flat-square)](https://travis-ci.org/diegohaz/redux-saga-thunk) [![Coverage Status](https://img.shields.io/codecov/c/github/diegohaz/redux-saga-thunk/master.svg?style=flat-square)](https://codecov.io/gh/diegohaz/redux-saga-thunk/branch/master) 7 | 8 | Dispatching an action handled by [redux-saga](https://github.com/redux-saga/redux-saga) returns promise. It looks like [redux-thunk](https://github.com/gaearon/redux-thunk), but with pure action creators. 9 | 10 | ```js 11 | class MyComponent extends React.Component { 12 | componentWillMount() { 13 |    // `doSomething` dispatches an action which is handled by some saga 14 |    this.props.doSomething().then((detail) => { 15 |      console.log('Yaay!', detail) 16 |    }).catch((error) => { 17 |      console.log('Oops!', error) 18 | }) 19 | } 20 | } 21 | ``` 22 | 23 | > `redux-saga-thunk` uses [Flux Standard Action](https://github.com/acdlite/flux-standard-action) to determine action's `payload`, `error` etc. 24 | 25 |
26 |
27 |

28 | If you find this useful, please don't forget to star ⭐️ the repo, as this will help to promote the project.
29 | Follow me on Twitter and GitHub to keep updated about this project and others. 30 |

31 |
32 |
33 | 34 | ## Motivation 35 | 36 | There are two reasons I created this library: Server Side Rendering and [redux-form](https://github.com/erikras/redux-form). 37 | 38 | When using [redux-saga](https://github.com/redux-saga/redux-saga) on server, you will need to know when your actions have been finished so you can send the response to the client. There are several ways to handle that case, and `redux-saga-thunk` approach is the one I like most. See [an example](https://github.com/diegohaz/arc/blob/d194b7e9578bdf3ad70a1e0d4c09ceca849f164e/src-example/containers/PostList.js#L33). 39 | 40 | With [redux-form](https://github.com/erikras/redux-form), you need to return a promise from `dispatch` inside your submit handler so it will know when the submission is complete. See [an example](https://github.com/diegohaz/arc/blob/8d46b9e52db3f1066b124b93cf8b92d05094fe1c/src-example/containers/PostForm.js#L10) 41 | 42 | Finally, that's a nice way to migrate your codebase from `redux-thunk` to `redux-saga`, since you will not need to change how you dispatch your actions, they will still return promises. 43 | 44 | ## Install 45 | 46 | $ npm install --save redux-saga-thunk 47 | 48 | ## Basic setup 49 | 50 | Add `middleware` to your redux configuration (**before redux-saga middleware**): 51 | 52 | ```js 53 | import { createStore, applyMiddleware } from 'redux' 54 | import createSagaMiddleware from 'redux-saga' 55 | import { middleware as thunkMiddleware } from 'redux-saga-thunk' 56 | ^ 57 | 58 | const sagaMiddleware = createSagaMiddleware() 59 | const store = createStore({}, applyMiddleware(thunkMiddleware, sagaMiddleware)) 60 |                                              ^ 61 | ``` 62 | 63 | ## Usage 64 | 65 | You just need to set `meta.thunk` to `true` on your request actions and put it on your response actions inside the saga: 66 | 67 | ```js 68 | const action = { 69 |  type: 'RESOURCE_REQUEST', 70 |  payload: { id: 'foo' }, 71 | meta: { 72 | thunk: true 73 |    ^ 74 |  } 75 | } 76 | 77 | // send the action 78 | store.dispatch(action).then((detail) => { 79 | // payload == detail 80 | console.log('Yaay!', detail) 81 | }).catch((e) => { 82 | // payload == e 83 | console.log('Oops!', e) 84 | }) 85 | 86 | function* saga() { 87 | while(true) { 88 |    const { payload, meta } = yield take('RESOURCE_REQUEST') 89 |                     ^ 90 |   try { 91 |      const detail = yield call(callApi, payload) // payload == { id: 'foo' } 92 | yield put({ 93 |        type: 'RESOURCE_SUCCESS', 94 | payload: detail, 95 | meta 96 |        ^ 97 |      }) 98 | } catch (e) { 99 | yield put({ 100 | type: 'RESOURCE_FAILURE', 101 | payload: e, 102 | error: true, 103 |        ^ 104 |        meta 105 |        ^ 106 | }) 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | `redux-saga-thunk` will automatically transform your request action and inject a `key` into it. 113 | 114 | You can also use it inside sagas with [`put.resolve`](https://redux-saga.js.org/docs/api/#putresolveaction): 115 | 116 | ```js 117 | function *someSaga() { 118 | try { 119 | const detail = yield put.resolve(action) 120 |    console.log('Yaay!', detail) 121 | } catch (error) { 122 |    console.log('Oops!', error) 123 | } 124 | } 125 | ``` 126 | 127 | ## Usage with selectors 128 | 129 | To use `pending`, `rejected`, `fulfilled` and `done` selectors, you'll need to add the `thunkReducer` to your store: 130 | 131 | ```js 132 | import { combineReducers } from 'redux' 133 | import { reducer as thunkReducer } from 'redux-saga-thunk' 134 | 135 | const reducer = combineReducers({ 136 | thunk: thunkReducer, 137 | // your reducers... 138 | }) 139 | ``` 140 | 141 | Now you can use selectors on your containers: 142 | 143 | ```js 144 | import { pending, rejected, fulfilled, done } from 'redux-saga-thunk' 145 | 146 | const mapStateToProps = state => ({ 147 | loading: pending(state, 'RESOURCE_CREATE_REQUEST'), 148 | error: rejected(state, 'RESOURCE_CREATE_REQUEST'), 149 | success: fulfilled(state, 'RESOURCE_CREATE_REQUEST'), 150 | done: done(state, 'RESOURCE_CREATE_REQUEST'), 151 | }) 152 | ``` 153 | 154 | ## API 155 | 156 | 157 | 158 | #### Table of Contents 159 | 160 | - [clean](#clean) 161 | - [pending](#pending) 162 | - [rejected](#rejected) 163 | - [fulfilled](#fulfilled) 164 | - [done](#done) 165 | 166 | ### clean 167 | 168 | Clean state 169 | 170 | **Parameters** 171 | 172 | - `name` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** 173 | - `id` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))** 174 | 175 | **Examples** 176 | 177 | ```javascript 178 | const mapDispatchToProps = (dispatch, ownProps) => ({ 179 | cleanFetchUserStateForAllIds: () => dispatch(clean('FETCH_USER')), 180 | cleanFetchUserStateForSpecifiedId: () => dispatch(clean('FETCH_USER', ownProps.id)), 181 | cleanFetchUsersState: () => dispatch(clean('FETCH_USERS')), 182 | }) 183 | ``` 184 | 185 | ### pending 186 | 187 | Tells if an action is pending 188 | 189 | **Parameters** 190 | 191 | - `state` **State** 192 | - `name` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) | \[[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String), ([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))])>)** 193 | - `id` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))** 194 | 195 | **Examples** 196 | 197 | ```javascript 198 | const mapStateToProps = state => ({ 199 | fooIsPending: pending(state, 'FOO'), 200 | barForId42IsPending: pending(state, 'BAR', 42), 201 | barForAnyIdIsPending: pending(state, 'BAR'), 202 | fooOrBazIsPending: pending(state, ['FOO', 'BAZ']), 203 | fooOrBarForId42IsPending: pending(state, ['FOO', ['BAR', 42]]), 204 | anythingIsPending: pending(state) 205 | }) 206 | ``` 207 | 208 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** 209 | 210 | ### rejected 211 | 212 | Tells if an action was rejected 213 | 214 | **Parameters** 215 | 216 | - `state` **State** 217 | - `name` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) | \[[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String), ([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))])>)** 218 | - `id` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))** 219 | 220 | **Examples** 221 | 222 | ```javascript 223 | const mapStateToProps = state => ({ 224 | fooWasRejected: rejected(state, 'FOO'), 225 | barForId42WasRejected: rejected(state, 'BAR', 42), 226 | barForAnyIdWasRejected: rejected(state, 'BAR'), 227 | fooOrBazWasRejected: rejected(state, ['FOO', 'BAZ']), 228 | fooOrBarForId42WasRejected: rejected(state, ['FOO', ['BAR', 42]]), 229 | anythingWasRejected: rejected(state) 230 | }) 231 | ``` 232 | 233 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** 234 | 235 | ### fulfilled 236 | 237 | Tells if an action is fulfilled 238 | 239 | **Parameters** 240 | 241 | - `state` **State** 242 | - `name` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) | \[[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String), ([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))])>)** 243 | - `id` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))** 244 | 245 | **Examples** 246 | 247 | ```javascript 248 | const mapStateToProps = state => ({ 249 | fooIsFulfilled: fulfilled(state, 'FOO'), 250 | barForId42IsFulfilled: fulfilled(state, 'BAR', 42), 251 | barForAnyIdIsFulfilled: fulfilled(state, 'BAR'), 252 | fooOrBazIsFulfilled: fulfilled(state, ['FOO', 'BAZ']), 253 | fooOrBarForId42IsFulfilled: fulfilled(state, ['FOO', ['BAR', 42]]), 254 | anythingIsFulfilled: fulfilled(state) 255 | }) 256 | ``` 257 | 258 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** 259 | 260 | ### done 261 | 262 | Tells if an action is done 263 | 264 | **Parameters** 265 | 266 | - `state` **State** 267 | - `name` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) | \[[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String), ([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))])>)** 268 | - `id` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))** 269 | 270 | **Examples** 271 | 272 | ```javascript 273 | const mapStateToProps = state => ({ 274 | fooIsDone: done(state, 'FOO'), 275 | barForId42IsDone: done(state, 'BAR', 42), 276 | barForAnyIdIsDone: done(state, 'BAR'), 277 | fooOrBazIsDone: done(state, ['FOO', 'BAZ']), 278 | fooOrBarForId42IsDone: done(state, ['FOO', ['BAR', 42]]), 279 | anythingIsDone: done(state) 280 | }) 281 | ``` 282 | 283 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** 284 | 285 | ## License 286 | 287 | MIT © [Diego Haz](https://github.com/diegohaz) 288 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "redux"; 2 | 3 | /** 4 | * Redux Saga Thunk Action 5 | * 6 | * ensures existence of [meta]: object in Redux Action with [thunk]: boolean 7 | */ 8 | export interface ReduxSagaThunkAction extends Action { 9 | meta: { 10 | thunk: boolean; 11 | [x: string]: any; 12 | }; 13 | } 14 | 15 | /** 16 | * Redux Saga Thunk Middleware 17 | */ 18 | export const middleware: any; 19 | 20 | /** 21 | * Redux Saga Thunk Selectors 22 | */ 23 | type ReduxSagaThunkSelector = (state: any, name: string | string[]) => boolean; 24 | 25 | export const pending: ReduxSagaThunkSelector; 26 | export const rejected: ReduxSagaThunkSelector; 27 | export const fulfilled: ReduxSagaThunkSelector; 28 | export const done: ReduxSagaThunkSelector; 29 | 30 | /** 31 | * Redux Saga Thunk Reducer 32 | */ 33 | export const reducer: any; 34 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist') 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-thunk", 3 | "version": "0.7.3", 4 | "description": "Dispatching an action handled by redux-saga returns promise", 5 | "license": "MIT", 6 | "repository": "diegohaz/redux-saga-thunk", 7 | "main": "index.js", 8 | "author": { 9 | "name": "Diego Haz", 10 | "email": "hazdiego@gmail.com", 11 | "url": "https://github.com/diegohaz" 12 | }, 13 | "engines": { 14 | "node": ">=6" 15 | }, 16 | "files": [ 17 | "dist", 18 | "index.js", 19 | "index.d.ts" 20 | ], 21 | "scripts": { 22 | "test": "jest", 23 | "coverage": "npm test -- --coverage", 24 | "postcoverage": "opn coverage/lcov-report/index.html", 25 | "lint": "eslint src test", 26 | "flow": "flow check", 27 | "docs": "documentation readme src --section=API", 28 | "clean": "rimraf dist", 29 | "prebuild": "npm run docs && npm run clean", 30 | "build": "babel src -d dist", 31 | "watch": "npm-watch", 32 | "patch": "npm version patch && npm publish", 33 | "minor": "npm version minor && npm publish", 34 | "major": "npm version major && npm publish", 35 | "prepublish": "npm run lint && npm test && npm run build", 36 | "postpublish": "git push origin master --follow-tags" 37 | }, 38 | "watch": { 39 | "test": "{src,test}/*.js", 40 | "lint": "{src,test}/*.js", 41 | "build": "src" 42 | }, 43 | "jest": { 44 | "testEnvironment": "node" 45 | }, 46 | "keywords": [ 47 | "redux-saga-thunk", 48 | "redux-saga", 49 | "redux", 50 | "async", 51 | "redux-thunk", 52 | "promise" 53 | ], 54 | "dependencies": { 55 | "lodash": "^4.17.4" 56 | }, 57 | "peerDependencies": { 58 | "redux": "*" 59 | }, 60 | "devDependencies": { 61 | "babel-cli": "^6.18.0", 62 | "babel-eslint": "^8.2.1", 63 | "babel-jest": "^22.0.6", 64 | "babel-plugin-transform-flow-strip-types": "^6.21.0", 65 | "babel-preset-env": "^1.1.8", 66 | "babel-preset-stage-2": "^6.18.0", 67 | "documentation": "6.0.0", 68 | "eslint": "^4.0.0", 69 | "eslint-config-airbnb-base": "^12.1.0", 70 | "eslint-config-prettier": "^2.9.0", 71 | "eslint-plugin-flowtype": "^2.29.2", 72 | "eslint-plugin-flowtype-errors": "^3.0.0", 73 | "eslint-plugin-import": "^2.2.0", 74 | "eslint-plugin-prettier": "^2.6.0", 75 | "flow-bin": "^0.66.0", 76 | "flux-standard-action": "^2.0.1", 77 | "jest-cli": "^22.0.6", 78 | "npm-watch": "^0.3.0", 79 | "opn-cli": "^3.1.0", 80 | "prettier": "^1.11.1", 81 | "redux": "^3.6.0", 82 | "redux-mock-store": "^1.2.3", 83 | "redux-saga": "^0.16.0", 84 | "rimraf": "^2.6.1" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | const PREFIX = '@@redux-saga-thunk/' 3 | 4 | export const CLEAN = `${PREFIX}clean` 5 | 6 | /** 7 | * Clean state 8 | * @example 9 | * const mapDispatchToProps = (dispatch, ownProps) => ({ 10 | * cleanFetchUserStateForAllIds: () => dispatch(clean('FETCH_USER')), 11 | * cleanFetchUserStateForSpecifiedId: () => dispatch(clean('FETCH_USER', ownProps.id)), 12 | * cleanFetchUsersState: () => dispatch(clean('FETCH_USERS')), 13 | * }) 14 | */ 15 | // eslint-disable-next-line import/prefer-default-export 16 | export function clean(name: string, id?: string | number) { 17 | return { 18 | type: CLEAN, 19 | meta: { 20 | thunk: arguments.length > 1 ? { name, id } : { name }, 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as middleware } from './middleware' 2 | export { default as reducer } from './reducer' 3 | export { pending, rejected, fulfilled, done } from './selectors' 4 | export { clean } from './actions' 5 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import { 2 | isThunkAction, 3 | isCleanAction, 4 | getThunkMeta, 5 | getThunkName, 6 | createThunkAction, 7 | generateThunk, 8 | hasKey, 9 | } from './utils' 10 | 11 | const middleware = () => { 12 | const responses = {} 13 | 14 | return next => action => { 15 | const { error, payload } = action 16 | if (isThunkAction(action) && !isCleanAction(action)) { 17 | if (!hasKey(action)) { 18 | const thunk = generateThunk(action) 19 | return new Promise((resolve, reject) => { 20 | responses[thunk.key] = (err, response) => 21 | err ? reject(response) : resolve(response) 22 | next(createThunkAction(action, thunk)) 23 | }) 24 | } 25 | const { key } = getThunkMeta(action) 26 | 27 | if (!responses[key]) { 28 | throw new Error( 29 | `[redux-saga-thunk] ${getThunkName( 30 | action, 31 | )} should be dispatched before ${action.type}`, 32 | ) 33 | } 34 | 35 | responses[key](error, payload) 36 | delete responses[key] 37 | return next(createThunkAction(action, generateThunk(action))) 38 | } 39 | return next(action) 40 | } 41 | } 42 | 43 | export default middleware 44 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | isThunkAction, 3 | isThunkRequestAction, 4 | isCleanAction, 5 | getThunkName, 6 | hasId, 7 | getThunkId, 8 | } from './utils' 9 | import { PENDING, REJECTED, FULFILLED, DONE, initialState } from './selectors' 10 | 11 | const transformSubstate = (substate, path, value) => { 12 | const name = path[0] 13 | let newValue = value 14 | 15 | if (path.length === 2) { 16 | const id = path[1] 17 | 18 | if (!value) { 19 | newValue = { ...substate[name] } 20 | delete newValue[id] 21 | } else { 22 | newValue = { ...substate[name], [id]: true } 23 | } 24 | 25 | if (Object.keys(newValue).length === 0) { 26 | newValue = false 27 | } 28 | } 29 | 30 | return { 31 | ...substate, 32 | [name]: newValue, 33 | } 34 | } 35 | 36 | const omit = (substate, path) => { 37 | const name = path[0] 38 | 39 | if (path.length === 1) { 40 | const newState = { ...substate } 41 | delete newState[name] 42 | return newState 43 | } 44 | 45 | if (typeof substate[name] !== 'object') { 46 | return substate 47 | } 48 | 49 | return transformSubstate(substate, path, false) 50 | } 51 | 52 | const cleanState = (state, path) => ({ 53 | ...state, 54 | [PENDING]: omit(state[PENDING], path), 55 | [REJECTED]: omit(state[REJECTED], path), 56 | [FULFILLED]: omit(state[FULFILLED], path), 57 | [DONE]: omit(state[DONE], path), 58 | }) 59 | 60 | const transformState = (state, path, pending, rejected, fulfilled, done) => ({ 61 | ...state, 62 | [PENDING]: transformSubstate(state[PENDING], path, pending), 63 | [REJECTED]: transformSubstate(state[REJECTED], path, rejected), 64 | [FULFILLED]: transformSubstate(state[FULFILLED], path, fulfilled), 65 | [DONE]: transformSubstate(state[DONE], path, done), 66 | }) 67 | 68 | export default (state = initialState, action) => { 69 | if (!isThunkAction(action)) return state 70 | const name = getThunkName(action) 71 | const path = hasId(action) ? [name, getThunkId(action)] : [name] 72 | 73 | if (isCleanAction(action)) return cleanState(state, path) 74 | 75 | if (isThunkRequestAction(action)) { 76 | return transformState(state, path, true, false, false, false) 77 | } else if (action.error) { 78 | return transformState(state, path, false, true, false, true) 79 | } 80 | return transformState(state, path, false, false, true, true) 81 | } 82 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import find from 'lodash/find' 3 | import pick from 'lodash/pick' 4 | 5 | export const PENDING = 'pending' 6 | export const REJECTED = 'rejected' 7 | export const FULFILLED = 'fulfilled' 8 | export const DONE = 'done' 9 | 10 | type ThunkState = { [string]: ?{} } 11 | 12 | type State = { 13 | thunk?: ThunkState, 14 | } 15 | 16 | export const initialState = { 17 | [PENDING]: {}, 18 | [REJECTED]: {}, 19 | [FULFILLED]: {}, 20 | [DONE]: {}, 21 | } 22 | 23 | const getIn = ( 24 | state: {}, 25 | name?: string | (string | [string, string | number])[], 26 | id?: string | number, 27 | ): boolean => { 28 | if (typeof name === 'undefined') { 29 | return !!find(state, value => !!value) 30 | } else if (Array.isArray(name)) { 31 | const names = name.map(n => (Array.isArray(n) ? n[0] : n)) 32 | const nameToIdMap = name.reduce((prev, curr) => { 33 | if (Array.isArray(curr)) { 34 | return Object.assign({}, prev, { [curr[0]]: curr[1] }) 35 | } 36 | return prev 37 | }, {}) 38 | 39 | return !!find(pick(state, names), (value, key) => { 40 | if (typeof nameToIdMap[key] === 'undefined') { 41 | return !!value 42 | } 43 | 44 | return typeof value === 'object' ? !!value[nameToIdMap[key]] : false 45 | }) 46 | } else if (Object.prototype.hasOwnProperty.call(state, name)) { 47 | if (typeof id === 'undefined') { 48 | return !!state[name] 49 | } 50 | return typeof state[name] === 'object' ? !!state[name][id] : false 51 | } 52 | return false 53 | } 54 | 55 | export const getThunkState = (state: State = {}): ThunkState => { 56 | if (!Object.prototype.hasOwnProperty.call(state, 'thunk')) { 57 | throw new Error('[redux-saga-thunk] There is no thunk state on reducer') 58 | } 59 | return state.thunk || {} 60 | } 61 | 62 | export const getPendingState = (state: State) => 63 | getThunkState(state)[PENDING] || initialState[PENDING] 64 | 65 | export const getRejectedState = (state: State) => 66 | getThunkState(state)[REJECTED] || initialState[REJECTED] 67 | 68 | export const getFulfilledState = (state: State) => 69 | getThunkState(state)[FULFILLED] || initialState[FULFILLED] 70 | 71 | export const getDoneState = (state: State) => 72 | getThunkState(state)[DONE] || initialState[DONE] 73 | 74 | /** 75 | * Tells if an action is pending 76 | * @example 77 | * const mapStateToProps = state => ({ 78 | * fooIsPending: pending(state, 'FOO'), 79 | * barForId42IsPending: pending(state, 'BAR', 42), 80 | * barForAnyIdIsPending: pending(state, 'BAR'), 81 | * fooOrBazIsPending: pending(state, ['FOO', 'BAZ']), 82 | * fooOrBarForId42IsPending: pending(state, ['FOO', ['BAR', 42]]), 83 | * anythingIsPending: pending(state) 84 | * }) 85 | */ 86 | export const pending = ( 87 | state: State, 88 | name?: string | (string | [string, string | number])[], 89 | id?: string | number, 90 | ): boolean => getIn(getPendingState(state), name, id) 91 | 92 | /** 93 | * Tells if an action was rejected 94 | * @example 95 | * const mapStateToProps = state => ({ 96 | * fooWasRejected: rejected(state, 'FOO'), 97 | * barForId42WasRejected: rejected(state, 'BAR', 42), 98 | * barForAnyIdWasRejected: rejected(state, 'BAR'), 99 | * fooOrBazWasRejected: rejected(state, ['FOO', 'BAZ']), 100 | * fooOrBarForId42WasRejected: rejected(state, ['FOO', ['BAR', 42]]), 101 | * anythingWasRejected: rejected(state) 102 | * }) 103 | */ 104 | export const rejected = ( 105 | state: State, 106 | name?: string | (string | [string, string | number])[], 107 | id?: string | number, 108 | ): boolean => getIn(getRejectedState(state), name, id) 109 | 110 | /** 111 | * Tells if an action is fulfilled 112 | * @example 113 | * const mapStateToProps = state => ({ 114 | * fooIsFulfilled: fulfilled(state, 'FOO'), 115 | * barForId42IsFulfilled: fulfilled(state, 'BAR', 42), 116 | * barForAnyIdIsFulfilled: fulfilled(state, 'BAR'), 117 | * fooOrBazIsFulfilled: fulfilled(state, ['FOO', 'BAZ']), 118 | * fooOrBarForId42IsFulfilled: fulfilled(state, ['FOO', ['BAR', 42]]), 119 | * anythingIsFulfilled: fulfilled(state) 120 | * }) 121 | */ 122 | export const fulfilled = ( 123 | state: State, 124 | name?: string | (string | [string, string | number])[], 125 | id?: string | number, 126 | ): boolean => getIn(getFulfilledState(state), name, id) 127 | 128 | /** 129 | * Tells if an action is done 130 | * @example 131 | * const mapStateToProps = state => ({ 132 | * fooIsDone: done(state, 'FOO'), 133 | * barForId42IsDone: done(state, 'BAR', 42), 134 | * barForAnyIdIsDone: done(state, 'BAR'), 135 | * fooOrBazIsDone: done(state, ['FOO', 'BAZ']), 136 | * fooOrBarForId42IsDone: done(state, ['FOO', ['BAR', 42]]), 137 | * anythingIsDone: done(state) 138 | * }) 139 | */ 140 | export const done = ( 141 | state: State, 142 | name?: string | (string | [string, string | number])[], 143 | id?: string | number, 144 | ): boolean => getIn(getDoneState(state), name, id) 145 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { CLEAN } from './actions' 2 | 3 | export const isThunkAction = action => 4 | !!(action && action.meta && action.meta.thunk) 5 | 6 | export const isThunkRequestAction = action => 7 | !!( 8 | isThunkAction(action) && 9 | typeof action.meta.thunk === 'object' && 10 | action.meta.thunk.type === 'REQUEST' 11 | ) 12 | 13 | export const isCleanAction = action => action.type === CLEAN 14 | 15 | export const getThunkMeta = action => { 16 | if (isThunkAction(action)) { 17 | return action.meta.thunk 18 | } 19 | return null 20 | } 21 | 22 | export const createThunkAction = (action, thunk) => ({ 23 | ...action, 24 | meta: { 25 | ...action.meta, 26 | thunk, 27 | }, 28 | }) 29 | 30 | export const getThunkName = action => { 31 | const meta = getThunkMeta(action) 32 | if (meta && typeof meta === 'string') { 33 | return meta 34 | } 35 | if (meta && typeof meta === 'object' && 'name' in meta) { 36 | return meta.name 37 | } 38 | return action.type 39 | } 40 | 41 | export const hasId = action => { 42 | const meta = getThunkMeta(action) 43 | return !!meta && typeof meta === 'object' && 'id' in meta 44 | } 45 | 46 | export const getThunkId = action => 47 | hasId(action) ? getThunkMeta(action).id : undefined 48 | 49 | export const hasKey = action => { 50 | const meta = getThunkMeta(action) 51 | return !!meta && typeof meta === 'object' && 'key' in meta 52 | } 53 | 54 | export const generateThunk = action => { 55 | const thunk = getThunkMeta(action) 56 | 57 | return hasKey(action) 58 | ? { 59 | ...thunk, 60 | type: 'RESPONSE', 61 | } 62 | : { 63 | ...(typeof thunk === 'object' ? thunk : {}), 64 | name: getThunkName(action), 65 | key: Math.random() 66 | .toFixed(16) 67 | .substring(2), 68 | type: 'REQUEST', 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/actions.test.js: -------------------------------------------------------------------------------- 1 | import { isFSA } from 'flux-standard-action' 2 | import { CLEAN, clean } from '../src/actions' 3 | 4 | describe('clean', () => { 5 | it('without ID', () => { 6 | const action = clean('FOO') 7 | 8 | expect(action).toEqual({ 9 | type: CLEAN, 10 | meta: { 11 | thunk: { 12 | name: 'FOO', 13 | }, 14 | }, 15 | }) 16 | expect(action).not.toHaveProperty(['meta', 'thunk', 'id']) 17 | expect(isFSA(action)).toBe(true) 18 | }) 19 | 20 | it('with ID', () => { 21 | const action = clean('FOO', 1) 22 | 23 | expect(action).toEqual({ 24 | type: CLEAN, 25 | meta: { 26 | thunk: { 27 | name: 'FOO', 28 | id: 1, 29 | }, 30 | }, 31 | }) 32 | expect(isFSA(action)).toBe(true) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers } from 'redux' 2 | import createSagaMiddleware, { delay } from 'redux-saga' 3 | import { put, take, call, fork, takeEvery } from 'redux-saga/effects' 4 | import { 5 | middleware as thunkMiddleware, 6 | reducer as thunkReducer, 7 | pending, 8 | rejected, 9 | fulfilled, 10 | done, 11 | clean, 12 | } from '../src' 13 | 14 | function* foo(payload, meta) { 15 | yield call(delay, 500) 16 | if (payload === 'done') { 17 | yield put({ 18 | type: 'FOO_SUCCESS', 19 | payload, 20 | meta: { 21 | thunk: meta.thunk, 22 | }, 23 | }) 24 | } else { 25 | yield put({ 26 | type: 'FOO_FAILURE', 27 | error: true, 28 | payload, 29 | meta: { 30 | thunk: meta.thunk, 31 | }, 32 | }) 33 | } 34 | } 35 | 36 | function* watchFoo() { 37 | // eslint-disable-next-line no-constant-condition 38 | while (true) { 39 | const { payload, meta } = yield take('FOO_REQUEST') 40 | yield call(foo, payload, meta) 41 | } 42 | } 43 | 44 | function* bar({ payload, meta }) { 45 | yield put({ type: 'BAR_SUCCESS', payload, meta }) 46 | } 47 | 48 | function* watchBar() { 49 | yield takeEvery('BAR_REQUEST', bar) 50 | } 51 | 52 | function* sagas() { 53 | yield fork(watchFoo) 54 | yield fork(watchBar) 55 | } 56 | 57 | const configureStore = () => { 58 | const sagaMiddleware = createSagaMiddleware() 59 | const middlewares = [thunkMiddleware, sagaMiddleware] 60 | const reducer = combineReducers({ thunk: thunkReducer }) 61 | const store = createStore(reducer, {}, applyMiddleware(...middlewares)) 62 | sagaMiddleware.run(sagas) 63 | return store 64 | } 65 | 66 | describe('Integration test', () => { 67 | const fooRequest = payload => ({ 68 | type: 'FOO_REQUEST', 69 | payload, 70 | meta: { 71 | thunk: true, 72 | }, 73 | }) 74 | 75 | const barRequest = payload => ({ 76 | type: 'BAR_REQUEST', 77 | payload, 78 | meta: { 79 | thunk: true, 80 | }, 81 | }) 82 | 83 | it('calls done', async () => { 84 | const { dispatch, getState } = configureStore() 85 | const promise = dispatch(fooRequest('done')) 86 | expect(promise).toBeInstanceOf(Promise) 87 | await delay(400) 88 | expect(pending(getState(), 'FOO_REQUEST')).toBe(true) 89 | expect(rejected(getState(), 'FOO_REQUEST')).toBe(false) 90 | expect(fulfilled(getState(), 'FOO_REQUEST')).toBe(false) 91 | expect(done(getState(), 'FOO_REQUEST')).toBe(false) 92 | await expect(promise).resolves.toBe('done') 93 | expect(pending(getState(), 'FOO_REQUEST')).toBe(false) 94 | expect(rejected(getState(), 'FOO_REQUEST')).toBe(false) 95 | expect(fulfilled(getState(), 'FOO_REQUEST')).toBe(true) 96 | expect(done(getState(), 'FOO_REQUEST')).toBe(true) 97 | }) 98 | 99 | it('calls failure', async () => { 100 | const { dispatch, getState } = configureStore() 101 | const promise = dispatch(fooRequest('failure')) 102 | expect(promise).toBeInstanceOf(Promise) 103 | await delay(400) 104 | expect(pending(getState(), 'FOO_REQUEST')).toBe(true) 105 | expect(rejected(getState(), 'FOO_REQUEST')).toBe(false) 106 | expect(fulfilled(getState(), 'FOO_REQUEST')).toBe(false) 107 | expect(done(getState(), 'FOO_REQUEST')).toBe(false) 108 | await expect(promise).rejects.toBe('failure') 109 | expect(pending(getState(), 'FOO_REQUEST')).toBe(false) 110 | expect(rejected(getState(), 'FOO_REQUEST')).toBe(true) 111 | expect(fulfilled(getState(), 'FOO_REQUEST')).toBe(false) 112 | expect(done(getState(), 'FOO_REQUEST')).toBe(true) 113 | }) 114 | 115 | it('calls done immediatly', async () => { 116 | const { dispatch, getState } = configureStore() 117 | const promise = dispatch(barRequest('done')) 118 | expect(promise).toBeInstanceOf(Promise) 119 | await expect(promise).resolves.toBe('done') 120 | expect(pending(getState(), 'BAR_REQUEST')).toBe(false) 121 | expect(rejected(getState(), 'BAR_REQUEST')).toBe(false) 122 | expect(fulfilled(getState(), 'BAR_REQUEST')).toBe(true) 123 | expect(done(getState(), 'BAR_REQUEST')).toBe(true) 124 | }) 125 | 126 | it('calls clean', async () => { 127 | const { dispatch, getState } = configureStore() 128 | const promise = dispatch(barRequest('done')) 129 | expect(promise).toBeInstanceOf(Promise) 130 | await expect(promise).resolves.toBe('done') 131 | expect(pending(getState(), 'BAR_REQUEST')).toBe(false) 132 | expect(rejected(getState(), 'BAR_REQUEST')).toBe(false) 133 | expect(fulfilled(getState(), 'BAR_REQUEST')).toBe(true) 134 | expect(done(getState(), 'BAR_REQUEST')).toBe(true) 135 | dispatch(clean('BAR_REQUEST')) 136 | expect(pending(getState(), 'BAR_REQUEST')).toBe(false) 137 | expect(rejected(getState(), 'BAR_REQUEST')).toBe(false) 138 | expect(fulfilled(getState(), 'BAR_REQUEST')).toBe(false) 139 | expect(done(getState(), 'BAR_REQUEST')).toBe(false) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /test/middleware.test.js: -------------------------------------------------------------------------------- 1 | import configureStore from 'redux-mock-store' 2 | import { middleware } from '../src' 3 | 4 | const mockStore = configureStore([middleware]) 5 | 6 | const actionType = 'FOO' 7 | 8 | const createAction = meta => ({ 9 | type: actionType, 10 | ...(meta ? { meta } : {}), 11 | }) 12 | 13 | it('dispatches the exactly same action when it has no meta', () => { 14 | const { dispatch, getActions } = mockStore({}) 15 | const action = createAction() 16 | expect(dispatch(action)).toEqual(action) 17 | expect(getActions()).toEqual([action]) 18 | }) 19 | 20 | it('dispatches the exactly same action when it has no meta.thunk', () => { 21 | const { dispatch, getActions } = mockStore({}) 22 | const action = createAction({}) 23 | expect(dispatch(action)).toEqual(action) 24 | expect(getActions()).toEqual([action]) 25 | }) 26 | 27 | it('dispatches an action with request key when it has meta.thunk but no key', () => { 28 | const { dispatch, getActions } = mockStore({}) 29 | const action = createAction({ thunk: true }) 30 | const expected = createAction({ 31 | thunk: { 32 | name: actionType, 33 | key: expect.stringMatching(/^\d{16}$/), 34 | type: 'REQUEST', 35 | }, 36 | }) 37 | expect(dispatch(action)).toBeInstanceOf(Promise) 38 | expect(getActions()).toEqual([expected]) 39 | }) 40 | 41 | it('throws an error when response action is dispatched before request action', () => { 42 | const { dispatch } = mockStore({}) 43 | const action = createAction({ 44 | thunk: { key: '1234567890123456', name: 'FOOBAR', type: 'REQUEST' }, 45 | }) 46 | expect(() => dispatch(action)).toThrow( 47 | '[redux-saga-thunk] FOOBAR should be dispatched before FOO', 48 | ) 49 | }) 50 | 51 | it('dispatches an action with response key when it has meta.thunk with key', () => { 52 | const { dispatch, getActions, clearActions } = mockStore({}) 53 | // dispatch request action 54 | dispatch(createAction({ thunk: 'FOOBAR' })) 55 | const { key } = getActions()[0].meta.thunk 56 | clearActions() 57 | 58 | const action = createAction({ 59 | thunk: { key, name: 'FOOBAR', type: 'REQUEST' }, 60 | }) 61 | const expected = createAction({ 62 | thunk: { key, name: 'FOOBAR', type: 'RESPONSE' }, 63 | }) 64 | expect(dispatch(action)).toEqual(expected) 65 | expect(getActions()).toEqual([expected]) 66 | }) 67 | -------------------------------------------------------------------------------- /test/reducer.test.js: -------------------------------------------------------------------------------- 1 | import { reducer } from '../src' 2 | import { 3 | PENDING, 4 | REJECTED, 5 | FULFILLED, 6 | DONE, 7 | initialState, 8 | } from '../src/selectors' 9 | import { clean } from '../src/actions' 10 | 11 | const action = (meta, error) => ({ 12 | type: 'FOO', 13 | error, 14 | ...(meta ? { meta } : {}), 15 | }) 16 | 17 | it('returns the initial state', () => { 18 | expect(reducer(undefined, {})).toEqual(initialState) 19 | expect(reducer(undefined, action())).toEqual(initialState) 20 | }) 21 | 22 | const expectStateToMatch = ( 23 | state, 24 | thunk, 25 | error, 26 | pending, 27 | rejected, 28 | fulfilled, 29 | done, 30 | ) => 31 | expect(reducer(state, action({ thunk }, error))).toEqual({ 32 | [PENDING]: { FOO: pending }, 33 | [REJECTED]: { FOO: rejected }, 34 | [FULFILLED]: { FOO: fulfilled }, 35 | [DONE]: { FOO: done }, 36 | }) 37 | 38 | it('handles PENDING', () => { 39 | expectStateToMatch( 40 | initialState, 41 | { 42 | key: '1234567890123456', 43 | name: 'FOO', 44 | type: 'REQUEST', 45 | }, 46 | false, 47 | true, 48 | false, 49 | false, 50 | false, 51 | ) 52 | expectStateToMatch( 53 | initialState, 54 | { 55 | key: '1234567890123456', 56 | name: 'FOO', 57 | type: 'REQUEST', 58 | id: 1, 59 | }, 60 | false, 61 | { 1: true }, 62 | false, 63 | false, 64 | false, 65 | ) 66 | expectStateToMatch( 67 | { 68 | ...initialState, 69 | [PENDING]: { FOO: { 2: true } }, 70 | }, 71 | { 72 | key: '1234567890123456', 73 | name: 'FOO', 74 | type: 'REQUEST', 75 | id: 1, 76 | }, 77 | false, 78 | { 1: true, 2: true }, 79 | false, 80 | false, 81 | false, 82 | ) 83 | }) 84 | 85 | it('handles FULFILLED', () => { 86 | expectStateToMatch( 87 | initialState, 88 | { 89 | key: '1234567890123456', 90 | name: 'FOO', 91 | type: 'RESPONSE', 92 | }, 93 | false, 94 | false, 95 | false, 96 | true, 97 | true, 98 | ) 99 | expectStateToMatch( 100 | initialState, 101 | { 102 | key: '1234567890123456', 103 | name: 'FOO', 104 | type: 'RESPONSE', 105 | id: 1, 106 | }, 107 | false, 108 | false, 109 | false, 110 | { 1: true }, 111 | { 1: true }, 112 | ) 113 | expectStateToMatch( 114 | { 115 | ...initialState, 116 | [PENDING]: { FOO: { 1: true } }, 117 | }, 118 | { 119 | key: '1234567890123456', 120 | name: 'FOO', 121 | type: 'RESPONSE', 122 | id: 1, 123 | }, 124 | false, 125 | false, 126 | false, 127 | { 1: true }, 128 | { 1: true }, 129 | ) 130 | }) 131 | 132 | it('handles REJECTED', () => { 133 | expectStateToMatch( 134 | initialState, 135 | { 136 | key: '1234567890123456', 137 | name: 'FOO', 138 | type: 'RESPONSE', 139 | }, 140 | true, 141 | false, 142 | true, 143 | false, 144 | true, 145 | ) 146 | expectStateToMatch( 147 | initialState, 148 | { 149 | key: '1234567890123456', 150 | name: 'FOO', 151 | type: 'RESPONSE', 152 | id: 1, 153 | }, 154 | true, 155 | false, 156 | { 1: true }, 157 | false, 158 | { 1: true }, 159 | ) 160 | expectStateToMatch( 161 | { 162 | ...initialState, 163 | [PENDING]: { FOO: { 1: true } }, 164 | }, 165 | { 166 | key: '1234567890123456', 167 | name: 'FOO', 168 | type: 'RESPONSE', 169 | id: 1, 170 | }, 171 | true, 172 | false, 173 | { 1: true }, 174 | false, 175 | { 1: true }, 176 | ) 177 | }) 178 | 179 | it('handles CLEAN', () => { 180 | expect( 181 | reducer( 182 | { 183 | ...initialState, 184 | [FULFILLED]: { FOO: true }, 185 | }, 186 | clean('FOO'), 187 | ), 188 | ).toEqual(initialState) 189 | 190 | expect( 191 | reducer( 192 | { 193 | ...initialState, 194 | [FULFILLED]: { FOO: { 1: true } }, 195 | }, 196 | clean('FOO', 1), 197 | ), 198 | ).toEqual({ 199 | ...initialState, 200 | [FULFILLED]: { FOO: false }, 201 | }) 202 | 203 | expect( 204 | reducer( 205 | { 206 | ...initialState, 207 | [FULFILLED]: { FOO: { 1: true, 2: false } }, 208 | }, 209 | clean('FOO', 2), 210 | ), 211 | ).toEqual({ 212 | ...initialState, 213 | [FULFILLED]: { FOO: { 1: true } }, 214 | }) 215 | 216 | expect( 217 | reducer( 218 | { 219 | ...initialState, 220 | [FULFILLED]: { FOO: { 1: true, 2: false } }, 221 | }, 222 | clean('FOO'), 223 | ), 224 | ).toEqual(initialState) 225 | }) 226 | -------------------------------------------------------------------------------- /test/selectors.test.js: -------------------------------------------------------------------------------- 1 | import * as selectors from '../src/selectors' 2 | 3 | const altState = { 4 | thunk: { 5 | [selectors.PENDING]: { 6 | FETCH_USER: false, 7 | FETCH_USERS: true, 8 | CREATE_USER: false, 9 | UPDATE_USER: { 10 | 1: true, 11 | }, 12 | }, 13 | [selectors.REJECTED]: { 14 | FETCH_USER: false, 15 | FETCH_USERS: false, 16 | CREATE_USER: { 17 | someuniqtempid: true, 18 | }, 19 | UPDATE_USER: { 20 | 1: true, 21 | }, 22 | }, 23 | [selectors.FULFILLED]: { 24 | FETCH_USER: false, 25 | FETCH_USERS: false, 26 | CREATE_USER: { 27 | someuniqtempid: true, 28 | }, 29 | UPDATE_USER: { 30 | 1: true, 31 | }, 32 | }, 33 | [selectors.DONE]: { 34 | FETCH_USER: false, 35 | FETCH_USERS: false, 36 | CREATE_USER: { 37 | someuniqtempid: true, 38 | }, 39 | UPDATE_USER: { 40 | 1: true, 41 | }, 42 | }, 43 | }, 44 | } 45 | 46 | test('initialState', () => { 47 | expect(selectors.initialState).toEqual({ 48 | [selectors.PENDING]: {}, 49 | [selectors.REJECTED]: {}, 50 | [selectors.FULFILLED]: {}, 51 | [selectors.DONE]: {}, 52 | }) 53 | }) 54 | 55 | test('getThunkState', () => { 56 | expect(() => selectors.getThunkState()).toThrow() 57 | expect(() => selectors.getThunkState({})).toThrow() 58 | expect(selectors.getThunkState({ thunk: {} })).toEqual({}) 59 | }) 60 | 61 | test('getPendingState', () => { 62 | expect(selectors.getPendingState({ thunk: undefined })).toEqual( 63 | selectors.initialState[selectors.PENDING], 64 | ) 65 | expect(selectors.getPendingState({ thunk: selectors.initialState })).toEqual( 66 | selectors.initialState[selectors.PENDING], 67 | ) 68 | expect(selectors.getPendingState(altState)).toEqual( 69 | altState.thunk[selectors.PENDING], 70 | ) 71 | }) 72 | 73 | test('getRejectedState', () => { 74 | expect(selectors.getRejectedState({ thunk: undefined })).toEqual( 75 | selectors.initialState[selectors.REJECTED], 76 | ) 77 | expect(selectors.getRejectedState({ thunk: selectors.initialState })).toEqual( 78 | selectors.initialState[selectors.REJECTED], 79 | ) 80 | expect(selectors.getRejectedState(altState)).toEqual( 81 | altState.thunk[selectors.REJECTED], 82 | ) 83 | }) 84 | test('getFulfilledState', () => { 85 | expect(selectors.getFulfilledState({ thunk: undefined })).toEqual( 86 | selectors.initialState[selectors.FULFILLED], 87 | ) 88 | expect( 89 | selectors.getFulfilledState({ thunk: selectors.initialState }), 90 | ).toEqual(selectors.initialState[selectors.FULFILLED]) 91 | expect(selectors.getFulfilledState(altState)).toEqual( 92 | altState.thunk[selectors.FULFILLED], 93 | ) 94 | }) 95 | 96 | describe('pending', () => { 97 | test('all', () => { 98 | expect(selectors.pending({ thunk: selectors.initialState })).toBe(false) 99 | expect(selectors.pending(altState)).toBe(true) 100 | }) 101 | 102 | test('with prefix', () => { 103 | expect( 104 | selectors.pending({ thunk: selectors.initialState }, 'FETCH_USER'), 105 | ).toBe(false) 106 | expect(selectors.pending(altState, 'FETCH_USER')).toBe(false) 107 | expect(selectors.pending(altState, 'FETCH_USERS')).toBe(true) 108 | expect(selectors.pending(altState, 'UPDATE_USER')).toBe(true) 109 | }) 110 | 111 | test('with prefix and identifier', () => { 112 | expect( 113 | selectors.pending({ thunk: selectors.initialState }, 'UPDATE_USER', 1), 114 | ).toBe(false) 115 | expect(selectors.pending(altState, 'FETCH_USER', 1)).toBe(false) 116 | expect(selectors.pending(altState, 'FETCH_USERS', 'some_identifier')).toBe( 117 | false, 118 | ) 119 | expect(selectors.pending(altState, 'UPDATE_USER', 1)).toBe(true) 120 | }) 121 | 122 | test('with array of prefixes', () => { 123 | expect( 124 | selectors.pending({ thunk: selectors.initialState }, ['FETCH_USER']), 125 | ).toBe(false) 126 | expect(selectors.pending(altState, ['FETCH_USER', 'CREATE_USER'])).toBe( 127 | false, 128 | ) 129 | expect(selectors.pending(altState, ['FETCH_USER', 'FETCH_USERS'])).toBe( 130 | true, 131 | ) 132 | expect(selectors.pending(altState, ['FETCH_USERS', 'FETCH_USER'])).toBe( 133 | true, 134 | ) 135 | expect(selectors.pending(altState, ['FETCH_USER', 'UPDATE_USER'])).toBe( 136 | true, 137 | ) 138 | }) 139 | 140 | test('with array of prefix+identifier pairs', () => { 141 | expect( 142 | selectors.pending({ thunk: selectors.initialState }, [['FETCH_USER', 1]]), 143 | ).toBe(false) 144 | expect( 145 | selectors.pending(altState, [['FETCH_USER', 1], 'CREATE_USER']), 146 | ).toBe(false) 147 | expect( 148 | selectors.pending(altState, [['FETCH_USER', 1], ['UPDATE_USER', 1]]), 149 | ).toBe(true) 150 | expect( 151 | selectors.pending(altState, [['FETCH_USER', 1], ['UPDATE_USER', 2]]), 152 | ).toBe(false) 153 | expect( 154 | selectors.pending(altState, [['FETCH_USER', 1], 'UPDATE_USER']), 155 | ).toBe(true) 156 | }) 157 | }) 158 | 159 | describe('rejected', () => { 160 | test('all', () => { 161 | expect(selectors.rejected({ thunk: selectors.initialState })).toBe(false) 162 | expect(selectors.rejected(altState)).toBe(true) 163 | }) 164 | 165 | test('with prefix', () => { 166 | expect( 167 | selectors.rejected({ thunk: selectors.initialState }, 'FETCH_USERS'), 168 | ).toBe(false) 169 | expect(selectors.rejected(altState, 'FETCH_USERS')).toBe(false) 170 | expect(selectors.rejected(altState, 'CREATE_USER')).toBe(true) 171 | }) 172 | 173 | test('with prefix and identifier', () => { 174 | expect( 175 | selectors.rejected({ thunk: selectors.initialState }, 'FETCH_USER', 1), 176 | ).toBe(false) 177 | expect(selectors.rejected(altState, 'FETCH_USER', 1)).toBe(false) 178 | expect(selectors.rejected(altState, 'CREATE_USER', 'someuniqtempid')).toBe( 179 | true, 180 | ) 181 | expect(selectors.rejected(altState, 'UPDATE_USER', 2)).toBe(false) 182 | }) 183 | 184 | test('with array of prefixes', () => { 185 | expect( 186 | selectors.rejected({ thunk: selectors.initialState }, ['FETCH_USER']), 187 | ).toBe(false) 188 | expect(selectors.rejected(altState, ['FETCH_USER', 'FETCH_USERS'])).toBe( 189 | false, 190 | ) 191 | expect(selectors.rejected(altState, ['FETCH_USER', 'CREATE_USER'])).toBe( 192 | true, 193 | ) 194 | expect(selectors.rejected(altState, ['CREATE_USER', 'FETCH_USER'])).toBe( 195 | true, 196 | ) 197 | }) 198 | 199 | test('with array of prefix+identifier pairs', () => { 200 | expect( 201 | selectors.rejected({ thunk: selectors.initialState }, [ 202 | ['FETCH_USER', 1], 203 | ]), 204 | ).toBe(false) 205 | expect( 206 | selectors.rejected(altState, [['FETCH_USER', 1], 'FETCH_USERS']), 207 | ).toBe(false) 208 | expect( 209 | selectors.rejected(altState, [['FETCH_USER', 1], ['UPDATE_USER', 1]]), 210 | ).toBe(true) 211 | expect( 212 | selectors.rejected(altState, [['FETCH_USER', 1], ['UPDATE_USER', 2]]), 213 | ).toBe(false) 214 | expect( 215 | selectors.rejected(altState, [['FETCH_USER', 1], 'UPDATE_USER']), 216 | ).toBe(true) 217 | }) 218 | }) 219 | 220 | describe('fulfilled', () => { 221 | test('all', () => { 222 | expect(selectors.fulfilled({ thunk: selectors.initialState })).toBe(false) 223 | expect(selectors.fulfilled(altState)).toBe(true) 224 | }) 225 | 226 | test('with prefix', () => { 227 | expect( 228 | selectors.fulfilled({ thunk: selectors.initialState }, 'FETCH_USERS'), 229 | ).toBe(false) 230 | expect(selectors.fulfilled(altState, 'FETCH_USERS')).toBe(false) 231 | expect(selectors.fulfilled(altState, 'CREATE_USER')).toBe(true) 232 | }) 233 | 234 | test('with prefix and identifier', () => { 235 | expect( 236 | selectors.fulfilled({ thunk: selectors.initialState }, 'FETCH_USER', 1), 237 | ).toBe(false) 238 | expect(selectors.fulfilled(altState, 'FETCH_USER', 1)).toBe(false) 239 | expect(selectors.fulfilled(altState, 'CREATE_USER', 'someuniqtempid')).toBe( 240 | true, 241 | ) 242 | expect(selectors.fulfilled(altState, 'UPDATE_USER', 2)).toBe(false) 243 | }) 244 | 245 | test('with array of prefixes', () => { 246 | expect( 247 | selectors.fulfilled({ thunk: selectors.initialState }, ['FETCH_USER']), 248 | ).toBe(false) 249 | expect(selectors.fulfilled(altState, ['FETCH_USER', 'FETCH_USERS'])).toBe( 250 | false, 251 | ) 252 | expect(selectors.fulfilled(altState, ['FETCH_USER', 'CREATE_USER'])).toBe( 253 | true, 254 | ) 255 | expect(selectors.fulfilled(altState, ['CREATE_USER', 'FETCH_USER'])).toBe( 256 | true, 257 | ) 258 | }) 259 | 260 | test('with array of prefix+identifier pairs', () => { 261 | expect( 262 | selectors.fulfilled({ thunk: selectors.initialState }, [ 263 | ['FETCH_USER', 1], 264 | ]), 265 | ).toBe(false) 266 | expect( 267 | selectors.fulfilled(altState, [['FETCH_USER', 1], 'FETCH_USERS']), 268 | ).toBe(false) 269 | expect( 270 | selectors.fulfilled(altState, [['FETCH_USER', 1], ['UPDATE_USER', 1]]), 271 | ).toBe(true) 272 | expect( 273 | selectors.fulfilled(altState, [['FETCH_USER', 1], ['UPDATE_USER', 2]]), 274 | ).toBe(false) 275 | expect( 276 | selectors.fulfilled(altState, [['FETCH_USER', 1], 'UPDATE_USER']), 277 | ).toBe(true) 278 | }) 279 | }) 280 | 281 | describe('done', () => { 282 | test('all', () => { 283 | expect(selectors.done({ thunk: selectors.initialState })).toBe(false) 284 | expect(selectors.done(altState)).toBe(true) 285 | }) 286 | 287 | test('with prefix', () => { 288 | expect( 289 | selectors.done({ thunk: selectors.initialState }, 'FETCH_USERS'), 290 | ).toBe(false) 291 | expect(selectors.done(altState, 'FETCH_USERS')).toBe(false) 292 | expect(selectors.done(altState, 'CREATE_USER')).toBe(true) 293 | }) 294 | 295 | test('with prefix and identifier', () => { 296 | expect( 297 | selectors.done({ thunk: selectors.initialState }, 'FETCH_USER', 1), 298 | ).toBe(false) 299 | expect(selectors.done(altState, 'FETCH_USER', 1)).toBe(false) 300 | expect(selectors.done(altState, 'CREATE_USER', 'someuniqtempid')).toBe(true) 301 | expect(selectors.done(altState, 'UPDATE_USER', 2)).toBe(false) 302 | }) 303 | 304 | test('with array of prefixes', () => { 305 | expect( 306 | selectors.done({ thunk: selectors.initialState }, ['FETCH_USER']), 307 | ).toBe(false) 308 | expect(selectors.done(altState, ['FETCH_USER', 'FETCH_USERS'])).toBe(false) 309 | expect(selectors.done(altState, ['FETCH_USER', 'CREATE_USER'])).toBe(true) 310 | expect(selectors.done(altState, ['CREATE_USER', 'FETCH_USER'])).toBe(true) 311 | }) 312 | 313 | test('with array of prefix+identifier pairs', () => { 314 | expect( 315 | selectors.done({ thunk: selectors.initialState }, [['FETCH_USER', 1]]), 316 | ).toBe(false) 317 | expect(selectors.done(altState, [['FETCH_USER', 1], 'FETCH_USERS'])).toBe( 318 | false, 319 | ) 320 | expect( 321 | selectors.done(altState, [['FETCH_USER', 1], ['UPDATE_USER', 1]]), 322 | ).toBe(true) 323 | expect( 324 | selectors.done(altState, [['FETCH_USER', 1], ['UPDATE_USER', 2]]), 325 | ).toBe(false) 326 | expect(selectors.done(altState, [['FETCH_USER', 1], 'UPDATE_USER'])).toBe( 327 | true, 328 | ) 329 | }) 330 | }) 331 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | isThunkAction, 3 | isThunkRequestAction, 4 | isCleanAction, 5 | getThunkMeta, 6 | createThunkAction, 7 | getThunkName, 8 | hasId, 9 | getThunkId, 10 | hasKey, 11 | generateThunk, 12 | } from '../src/utils' 13 | import { CLEAN } from '../src/actions' 14 | 15 | const actionType = 'FOO' 16 | 17 | const action = meta => ({ 18 | type: actionType, 19 | ...(meta ? { meta } : {}), 20 | }) 21 | 22 | test('isThunkAction', () => { 23 | expect(isThunkAction(action())).toBe(false) 24 | expect(isThunkAction(action({}))).toBe(false) 25 | expect(isThunkAction(action({ thunk: {} }))).toBe(true) 26 | expect(isThunkAction(action({ thunk: { id: 1 } }))).toBe(true) 27 | expect(isThunkAction(action({ thunk: true }))).toBe(true) 28 | }) 29 | 30 | test('isThunkRequestAction', () => { 31 | expect(isThunkRequestAction(action())).toBe(false) 32 | expect(isThunkRequestAction(action({}))).toBe(false) 33 | expect(isThunkRequestAction(action({ thunk: true }))).toBe(false) 34 | expect(isThunkRequestAction(action({ thunk: { id: 1 } }))).toBe(false) 35 | expect(isThunkRequestAction(action({ thunk: 'FOO' }))).toBe(false) 36 | expect(isThunkRequestAction(action({ thunk: { type: 'REQUEST' } }))).toBe( 37 | true, 38 | ) 39 | }) 40 | 41 | test('isCleanAction', () => { 42 | expect(isCleanAction(action())).toBe(false) 43 | expect(isCleanAction({ type: CLEAN })).toBe(true) 44 | }) 45 | 46 | test('getThunkMeta', () => { 47 | expect(getThunkMeta(action({}))).toBeNull() 48 | expect(getThunkMeta(action({ thunk: true }))).toBe(true) 49 | expect(getThunkMeta(action({ thunk: { id: 1 } }))).toEqual({ id: 1 }) 50 | }) 51 | 52 | test('createThunkAction', () => { 53 | expect(createThunkAction(action(), 'foo')).toEqual(action({ thunk: 'foo' })) 54 | }) 55 | 56 | test('getThunkName', () => { 57 | expect(getThunkName(action())).toBe(actionType) 58 | expect(getThunkName(action({}))).toBe(actionType) 59 | expect(getThunkName(action({ thunk: true }))).toBe(actionType) 60 | expect(getThunkName(action({ thunk: { id: 1 } }))).toBe(actionType) 61 | expect(getThunkName(action({ thunk: 'BAR' }))).toBe('BAR') 62 | expect(getThunkName(action({ thunk: { name: 'BAR' } }))).toBe('BAR') 63 | }) 64 | 65 | test('hasId', () => { 66 | expect(hasId(action({}))).toBe(false) 67 | expect(hasId(action({ thunk: true }))).toBe(false) 68 | expect(hasId(action({ thunk: { id: 1 } }))).toBe(true) 69 | expect(hasId(action({ thunk: { id: undefined } }))).toBe(true) 70 | expect(hasId(action({ thunk: 'FOO' }))).toBe(false) 71 | }) 72 | 73 | test('getThunkId', () => { 74 | expect(getThunkId(action({}))).toBe(undefined) 75 | expect(getThunkId(action({ thunk: true }))).toBe(undefined) 76 | expect(getThunkId(action({ thunk: { id: 1 } }))).toBe(1) 77 | expect(getThunkId(action({ thunk: 'FOO' }))).toBe(undefined) 78 | }) 79 | 80 | test('hasKey', () => { 81 | expect(hasKey(action())).toBe(false) 82 | expect(hasKey(action({}))).toBe(false) 83 | expect(hasKey(action({ thunk: true }))).toBe(false) 84 | expect(hasKey(action({ thunk: { id: 1 } }))).toBe(false) 85 | expect(hasKey(action({ thunk: 'FOO' }))).toBe(false) 86 | expect(hasKey(action({ thunk: { key: '1234567890123456' } }))).toBe(true) 87 | }) 88 | 89 | test('generateThunk', () => { 90 | expect(generateThunk(action({ thunk: 'FOO' }))).toMatchObject({ 91 | key: expect.stringMatching(/^\d{16}/), 92 | name: 'FOO', 93 | type: 'REQUEST', 94 | }) 95 | expect(generateThunk(action({ thunk: true }))).toMatchObject({ 96 | key: expect.stringMatching(/^\d{16}/), 97 | name: actionType, 98 | type: 'REQUEST', 99 | }) 100 | expect(generateThunk(action({ thunk: { id: 1 } }))).toMatchObject({ 101 | id: 1, 102 | key: expect.stringMatching(/^\d{16}/), 103 | name: actionType, 104 | type: 'REQUEST', 105 | }) 106 | expect(generateThunk(action())).toMatchObject({ 107 | key: expect.stringMatching(/^\d{16}/), 108 | name: actionType, 109 | type: 'REQUEST', 110 | }) 111 | expect( 112 | generateThunk( 113 | action({ thunk: { key: '1234567890123456', type: 'REQUEST' } }), 114 | ), 115 | ).toMatchObject({ type: 'RESPONSE' }) 116 | }) 117 | --------------------------------------------------------------------------------