├── .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 | [](https://github.com/diegohaz/nod)
4 | [](https://npmjs.org/package/redux-saga-thunk)
5 | [](https://npmjs.org/package/redux-saga-thunk)
6 | [](https://travis-ci.org/diegohaz/redux-saga-thunk) [](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 |
--------------------------------------------------------------------------------