├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json └── src ├── forceArray.js ├── forceArray.test.js ├── index.js ├── index.test.js ├── timeTransform.js └── timeTransform.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": [ 4 | "transform-object-rest-spread" 5 | ] 6 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | /coverage 4 | /lib 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | coverage -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | before_install: 5 | - npm install -g codecov 6 | script: 7 | - npm run test:coverage && codecov -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Domagoj Kriskovic 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/domagojk/redux-orchestrate.svg?branch=master)](https://travis-ci.org/domagojk/redux-orchestrate) 2 | [![codecov](https://codecov.io/gh/domagojk/redux-orchestrate/branch/master/graph/badge.svg)](https://codecov.io/gh/domagojk/redux-orchestrate) 3 | [![NPM Status](https://img.shields.io/npm/v/redux-orchestrate.svg?style=flat-square)](https://www.npmjs.com/package/redux-orchestrate) 4 | [![NPM Status](https://img.shields.io/npm/l/redux-orchestrate.svg?style=flat-square)](https://github.com/domagojk/redux-orchestrate/blob/master/LICENSE) 5 | 6 | # Redux Orchestrate 7 | Simple alternative to [redux-saga](https://github.com/redux-saga/redux-saga) or [redux-observable](https://github.com/redux-observable/redux-observable). 8 | 9 | Rather than using generators or Observables, most common operations are defined with a simple config object. 10 | 11 | ## Installation 12 | ```bash 13 | npm install --save redux-orchestrate 14 | ``` 15 | 16 | ## Usage 17 | 18 | ```javascript 19 | import { createStore, applyMiddleware } from 'redux' 20 | import orchestrate from 'redux-orchestrate' 21 | import reducer from './reducers' 22 | 23 | const processManager = [ 24 | // process manager logic 25 | ] 26 | 27 | // pass rules directly to middleware 28 | const store = createStore(reducer, applyMiddleware(orchestrate(processManager))) 29 | ``` 30 | 31 | ### Tranform 32 | In case of action(s) `X` -> dispatch action(s) `Y` 33 | 34 | ```javascript 35 | const processManager = [ 36 | { 37 | case: [ 38 | SEND_MESSAGE_BUTTON_CLICKED, 39 | MESSAGE_INPUT_ENTER_KEY_PRESSED 40 | ], 41 | dispatch: ADD_MESSAGE 42 | } 43 | ] 44 | ``` 45 | 46 | ### Cascade 47 | In case of action(s) `X` -> dispatch action(s) `Y` 48 | 49 | In case of action(s) `Y` -> dispatch action(s) `Z` 50 | 51 | ```javascript 52 | const processManager = [ 53 | { 54 | case: [ 55 | SEND_MESSAGE_BUTTON_CLICKED, 56 | MESSAGE_INPUT_ENTER_KEY_PRESSED 57 | ], 58 | dispatch: ADD_MESSAGE 59 | }, 60 | { 61 | case: ADD_MESSAGE, 62 | dispatch: [ 63 | ANOTHER_ACTION, 64 | ONE_MORE 65 | ] 66 | } 67 | ] 68 | ``` 69 | 70 | ### Delay 71 | In case of action(s) `X` -> wait for `k` miliseconds -> dispatch action(s) `Y` 72 | 73 | ```javascript 74 | const processManager = [ 75 | { 76 | case: [ 77 | SEND_MESSAGE_BUTTON_CLICKED, 78 | MESSAGE_INPUT_ENTER_KEY_PRESSED 79 | ], 80 | delay: 500 81 | dispatch: ADD_MESSAGE 82 | } 83 | ] 84 | ``` 85 | 86 | ### Debounce 87 | In case of action(s) `X` -> debounce for `k` miliseconds -> dispatch action(s) `Y` 88 | 89 | ```javascript 90 | const processManager = [ 91 | { 92 | case: [ 93 | SEND_MESSAGE_BUTTON_CLICKED, 94 | MESSAGE_INPUT_ENTER_KEY_PRESSED 95 | ], 96 | debounce: 500 97 | dispatch: ADD_MESSAGE 98 | } 99 | ] 100 | ``` 101 | 102 | ### Dispatch Logic 103 | In case of action(s) `X` -> perform logic using orignal `action` and `state` -> dispatch action(s) `Y` 104 | 105 | ```javascript 106 | const processManager = [ 107 | { 108 | case: [ 109 | SEND_MESSAGE_BUTTON_CLICKED, 110 | MESSAGE_INPUT_ENTER_KEY_PRESSED 111 | ], 112 | dispatch: (action, state) => { 113 | if (state.canAddMessage) { 114 | return { ...action, type: ADD_MESSAGE } 115 | } 116 | } 117 | } 118 | ] 119 | ``` 120 | 121 | ### Ajax Request 122 | In case of action(s) `X` -> make an ajax request -> 123 | 124 | -> in case of `success` -> dispatch `Y` 125 | 126 | -> in case of `failure` -> dispatch `Z` 127 | 128 | ```javascript 129 | const processManager = [ 130 | { 131 | case: ADD_MESSAGE, 132 | get: { 133 | url: 'https://server.com', 134 | onSuccess: MESSAGE_SENT, 135 | onFail: MESSAGE_SENDING_ERROR, 136 | } 137 | } 138 | ] 139 | ``` 140 | 141 | `post` request using `action.payload`: 142 | 143 | ```javascript 144 | const processManager = [ 145 | { 146 | case: ADD_MESSAGE, 147 | post: action => ({ 148 | url: 'https://server.com/new', 149 | data: { 150 | content: action.payload 151 | }, 152 | onSuccess: { type: MESSAGE_SENT, id: action.id }, 153 | onFail: { type: MESSAGE_SENDING_ERROR, id: action.id } 154 | }) 155 | } 156 | ] 157 | ``` 158 | 159 | making use od `res` and `err` response object from `onSuccess` and `onFail`: 160 | 161 | ```javascript 162 | const processManager = [ 163 | { 164 | case: ADD_MESSAGE, 165 | post: action => ({ 166 | url: 'https://server.com/new', 167 | data: { 168 | content: action.payload 169 | }, 170 | onSuccess: res => ({ 171 | type: MESSAGE_SENT, 172 | dataFromRes: res.data 173 | id: action.id 174 | }), 175 | onFail: err => ({ 176 | type: MESSAGE_SENDING_ERROR, 177 | errorMessage: err.message 178 | id: action.id 179 | }) 180 | }) 181 | } 182 | ] 183 | ``` 184 | 185 | ### Request Cancelation 186 | In case of action(s) `X` -> make an ajax request -> 187 | 188 | in case of action(s) `Y` -> cancel ajax request 189 | 190 | ```javascript 191 | const processManager = [ 192 | { 193 | case: ADD_MESSAGE, 194 | post: { 195 | url: `http://server.com`, 196 | cancelWhen: [ 197 | STOP_SENDING 198 | ], 199 | onSuccess: MESSAGE_SENT 200 | } 201 | } 202 | ] 203 | ``` 204 | 205 | ### Autocomplete example 206 | Now let's say we need to implement an autocomplete feature. 207 | In short, these are feature requirements: 208 | - Any time the user changes an input field, make a network request 209 | - If network request is not completed, but user had changed the input field again, cancel the previous request 210 | - Don't spam "suggestion server". Make the request when user had stopped typing, by debouncing its events. 211 | 212 | ```javascript 213 | const processManager = [ 214 | { 215 | case: SEARCH_INPUT_CHARACTER_ENTERED, // in case user has changed an input field 216 | debounce: 500, // wait for user to stop typing (debouncing by 500ms) 217 | get: action => ({ 218 | url: `http://s.co/${action.payload}`, // make a get request to a "suggestion server" 219 | cancelWhen: [ 220 | SEARCH_INPUT_CHARACTER_ENTERED, // in case user starts typing again, cancel request 221 | SEARCH_INPUT_BLURED // in case user is not using an input field, cancel request 222 | ], 223 | onSuccess: res => ({ 224 | type: AUTOCOMPLETE_SUGGESTION, // if query was successful, dispatch an event 225 | payload: res.data 226 | }) 227 | }) 228 | } 229 | ] 230 | ``` 231 | 232 | ### Cascade - more complex example 233 | 234 | ```javascript 235 | const processManager = [ 236 | { 237 | case: ADD_MESSAGE, 238 | post: (action, state) => ({ 239 | url: 'https://chat.app.com/new', 240 | data: { 241 | content: action.payload 242 | }, 243 | onSuccess: () => { 244 | if (state.canMarkAsSent) { 245 | return { ...action, type: MESSAGE_SENT } 246 | } else { 247 | return { ...action, type: FOR_SOME_REASON_THIS_IS_DISPATHCED } 248 | } 249 | } 250 | }) 251 | }, 252 | { 253 | case: FOR_SOME_REASON_THIS_IS_DISPATHCED 254 | post: (action, state) => ({ 255 | url: 'https://what.is.happening', 256 | data: { 257 | content: action.payload 258 | }, 259 | onSuccess: MESSAGE_SENT, 260 | onFail: MESSAGE_SENDING_ERROR 261 | }) 262 | } 263 | ] 264 | ``` 265 | 266 | ### Dynamically added rules 267 | Sometimes you may wish to add rules dynamically after middleware has been applied: 268 | 269 | ```javascript 270 | const processManager = [ 271 | // initial rules 272 | ] 273 | const orchestrateMiddleware = orchestrate(processManager) 274 | 275 | const store = createStore(reducer, applyMiddleware(orchestrateMiddleware)) 276 | orchestrateMiddleware.addRules([ 277 | // additional rules added dynamically 278 | ]) 279 | ``` 280 | 281 | ## FAQ 282 | 283 | ### Ok, but what about other kind of async operations? 284 | This middleware is not an attempt to solve all your problems. If you need to handle more complex async operations which are better solved by some other tools (generators, observables), then you should use middlewares that supports them or define your own ([it's not that hard](http://redux.js.org/docs/advanced/Middleware.html)). 285 | 286 | Also, don't forget that you can combine multiple middlewares. 287 | 288 | **Note**: additional operators could be supported in the future (but only if they don't significantly complicate the existing API). 289 | 290 | ### Can I use custom headers or similar options for ajax requests? 291 | Yes. 292 | 293 | redux-orchestrate uses [axios](https://github.com/mzabriskie/axios) for making network requests. 294 | 295 | All options passed in `request` (or aliases like `post`, `get`, etc.) is mapped with [axios request config](https://github.com/mzabriskie/axios#request-config) 296 | 297 | ### What is a process manager? 298 | Config object which defines the middleware logic is here reffered as "process manager". 299 | 300 | This term is borrowed from [CQRS/ES terminology](https://msdn.microsoft.com/en-us/library/jj591569.aspx) where the same concept is also referred as "saga" - "a piece of code that coordinates and routes messages between *bounded contexts* and *aggregates*". 301 | 302 | ### Why "orchestrate"? 303 | Term "orchestrate" is used to reffer to a single, central point for coordinating multiple entities and making them less coupled. 304 | 305 | This is a broad term, usually used in [service-oriented arhitectures](https://en.wikipedia.org/wiki/Service-oriented_architecture) and [compared with its opossite concept](https://www.infoq.com/news/2008/09/Orchestration) - "choreography" 306 | 307 | ## API 308 | 309 | ### Applying middleware: 310 | `orchestrate(processManager, options)` 311 | 312 | ### Process Manager 313 | The main array of objects defining action coordination. 314 | 315 | ```javascript 316 | const processManager = [ 317 | { 318 | case: [ 319 | IN_CASE_THIS_EVENT_IS_DISPATCHED, 320 | OR_THIS_EVENT 321 | ], 322 | dispatch: DISPATCH_THAT_EVENT, 323 | debounce: 500, 324 | delay: 500, 325 | request: { 326 | method: 'get', 327 | url: 'url', 328 | cancelWhen: [ 329 | IF_REQUEST_IS_PENDING_CANCEL_IT_WHEN_THIS_IS_DISPATCHED, 330 | OR_THIS 331 | ], 332 | onSuccess: DISPATCH_THIS_IF_AJAX_SUCCEDED 333 | onFail: DISPATCH_THIS_IF_AJAX_FAILED, 334 | // other axios props 335 | } 336 | } 337 | ] 338 | ``` 339 | 340 | #### Case 341 | Proceed with dispatching or making a request if action type is matched with the one defined in `case`. 342 | 343 | ```javascript 344 | { 345 | // string 346 | case: 'EVENT', 347 | // array 348 | case: [ 349 | 'EVENT_1', 350 | 'EVENT_2' 351 | ], 352 | // function 353 | case: (action, state) => `PREFIX_${action.type}` 354 | } 355 | ``` 356 | 357 | #### Dispatch 358 | Synchronously dispatch an action 359 | 360 | ```javascript 361 | { 362 | // string 363 | dispatch: 'EVENT', // dispatch action results in { type: 'EVENT' } 364 | // function 365 | dispatch: (action, state) => ({ type: `PREFIX_${action.type}` }) 366 | } 367 | ``` 368 | 369 | #### Request 370 | Make an ajax request using [axios](https://github.com/mzabriskie/axios) library. 371 | 372 | ```javascript 373 | { 374 | // object 375 | request: { 376 | method: 'get', 377 | url: 'url', 378 | cancelWhen: [ 379 | 'IF_REQUEST_IS_PENDING_CANCEL_IT_WHEN_THIS_IS_DISPATCHED', 380 | 'OR_THIS' 381 | ], 382 | onSuccess: 'DISPATCH_THIS_IF_AJAX_SUCCEDED' 383 | onFail: 'DISPATCH_THIS_IF_AJAX_FAILED', 384 | // other axios props 385 | }, 386 | // function 387 | request: (action, state) => { ... } 388 | } 389 | ``` 390 | For convenience aliases have been provided for all supported request methods: 391 | 392 | ```javascript 393 | { 394 | post: { ... }, 395 | get: { ... }, 396 | del: { ... }, 397 | head: { ... }, 398 | options: { ... }, 399 | put: { ... } 400 | patch: { ... } 401 | } 402 | ``` 403 | 404 | #### Debounce 405 | Dispatch event or make a request, after an action is debounced 406 | 407 | ```javascript 408 | { 409 | // integer 410 | debounce: 500, // in ms 411 | // function 412 | debounce: (action, state) => state.debounceConfig 413 | } 414 | ``` 415 | 416 | #### Delay 417 | Dispatch event or make a request, after an action is delayed 418 | 419 | ```javascript 420 | { 421 | // integer 422 | delay: 500, // in ms 423 | // function 424 | delay: (action, state) => state.delayConfig 425 | } 426 | ``` 427 | 428 | ### Options 429 | 430 | #### Validate 431 | If defined, no events will reach a reducer unless it's defined in a process manager. 432 | 433 | ```javascript 434 | { 435 | validate: false // default 436 | } 437 | ``` 438 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-orchestrate", 3 | "version": "1.2.0", 4 | "description": "Redux middleware for coordinating actions and handling side effects.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "test:coverage": "jest --coverage", 10 | "codecov": "jest && codecov --token=d3717906-3f60-47ed-9bc9-2983b6daaab0", 11 | "build": "babel src -d ./lib", 12 | "prepublish": "npm test && npm run build" 13 | }, 14 | "keywords": [ 15 | "redux", 16 | "middleware", 17 | "thunk", 18 | "async", 19 | "cancel", 20 | "debounce", 21 | "saga" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/domagojk/redux-orchestrate.git" 26 | }, 27 | "author": "Domagoj Kriskovic", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "babel-cli": "^6.24.1", 31 | "babel-jest": "^20.0.0", 32 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 33 | "babel-preset-es2015": "^6.24.1", 34 | "eslint": "^3.19.0", 35 | "eslint-config-standard": "^10.2.1", 36 | "eslint-plugin-import": "^2.3.0", 37 | "eslint-plugin-node": "^4.2.2", 38 | "eslint-plugin-promise": "^3.5.0", 39 | "eslint-plugin-standard": "^3.0.1", 40 | "jest": "^20.0.0", 41 | "redux": "^3.6.0", 42 | "regenerator-runtime": "^0.10.5" 43 | }, 44 | "dependencies": { 45 | "axios": "^0.16.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/forceArray.js: -------------------------------------------------------------------------------- 1 | function forceArray (arr) { 2 | if (!Array.isArray(arr)) return [arr] 3 | return arr 4 | } 5 | 6 | export default forceArray -------------------------------------------------------------------------------- /src/forceArray.test.js: -------------------------------------------------------------------------------- 1 | import forceArray from './forceArray' 2 | 3 | it('should return an array', () => { 4 | expect(forceArray(1)).toBeInstanceOf(Array) 5 | }) 6 | 7 | it('should return an array', () => { 8 | expect(forceArray([1,2,3])).toBeInstanceOf(Array) 9 | }) -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import forceArray from './forceArray' 3 | import timeTransform from './timeTransform' 4 | 5 | const CancelToken = axios.CancelToken 6 | const CancelMessage = { type: 'CANCEL_EVENT' } 7 | 8 | const orchestrate = (config = [], options) => { 9 | const middleware = store => next => originalAction => { 10 | if (!Array.isArray(config)) { 11 | throw new Error('Orchestrate config must be an array') 12 | } 13 | 14 | if (!options || !options.validate) { 15 | next(originalAction) 16 | } 17 | 18 | function internalNext (action, refAction) { 19 | forceArray(action).forEach(a => { 20 | if (typeof a === 'string') { 21 | a = {...refAction, type: a} 22 | } 23 | next(a) 24 | checkAction(a) 25 | }) 26 | } 27 | 28 | function checkAction (action) { 29 | config.forEach(rule => { 30 | if ( 31 | rule._cancelFn && 32 | rule._cancelWhen && 33 | rule._cancelWhen.indexOf(action.type) !== -1 34 | ) { 35 | rule._cancelFn(CancelMessage) 36 | } 37 | 38 | if (typeof rule._debounceTimeoutRefs !== 'object') { 39 | rule._debounceTimeoutRefs = {} 40 | } 41 | 42 | const ruleConfig = {} 43 | Object.keys(rule).forEach(ruleKey => { 44 | if (typeof rule[ruleKey] === 'function') { 45 | ruleConfig[ruleKey] = rule[ruleKey](action, store.getState()) 46 | } else { 47 | ruleConfig[ruleKey] = rule[ruleKey] 48 | } 49 | }) 50 | 51 | if (forceArray(ruleConfig.case).indexOf(action.type) === -1) { 52 | return 53 | } 54 | 55 | let dispatchAction = ruleConfig.dispatch 56 | let requestConfig = ruleConfig.request 57 | 58 | const supportMethods = { 59 | get: 'get', 60 | post: 'post', 61 | put: 'put', 62 | patch: 'patch', 63 | del: 'delete', 64 | head: 'head', 65 | options: 'options' 66 | } 67 | Object.keys(supportMethods).forEach(method => { 68 | if (ruleConfig[method]) { 69 | requestConfig = {...ruleConfig[method], method: supportMethods[method]} 70 | } 71 | }) 72 | 73 | timeTransform(action, ruleConfig, () => { 74 | if (dispatchAction) { 75 | internalNext(dispatchAction, action) 76 | } 77 | 78 | if (requestConfig) { 79 | axios({ 80 | ...requestConfig, 81 | cancelToken: new CancelToken(c => { 82 | rule._cancelFn = c 83 | rule._cancelWhen = requestConfig.cancelWhen 84 | }) 85 | }) 86 | .then(res => { 87 | if (requestConfig.onSuccess) { 88 | let onSuccessAction = requestConfig.onSuccess 89 | if (typeof requestConfig.onSuccess === 'function') { 90 | onSuccessAction = requestConfig.onSuccess(res) 91 | } 92 | internalNext(onSuccessAction, {}) 93 | } 94 | 95 | if (requestConfig.callback) { 96 | requestConfig.callback(null, res) 97 | } 98 | }) 99 | .catch(err => { 100 | if ( 101 | requestConfig.onFail && 102 | !(err && err.message && err.message === CancelMessage) 103 | ) { 104 | let onFailAction = requestConfig.onFail 105 | if (typeof requestConfig.onFail === 'function') { 106 | onFailAction = requestConfig.onFail(err) 107 | } 108 | internalNext(onFailAction, {}) 109 | } 110 | 111 | if (requestConfig.callback) { 112 | requestConfig.callback(err) 113 | } 114 | }) 115 | } 116 | }) 117 | }) 118 | } 119 | checkAction(originalAction) 120 | } 121 | 122 | middleware.addRules = rules => { 123 | config.push(...rules) 124 | } 125 | 126 | return middleware 127 | } 128 | 129 | export default orchestrate 130 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import orchestrate from './index' 2 | import { createStore, applyMiddleware } from 'redux' 3 | 4 | function getActions(config, options, dispatcher) { 5 | const actions = [] 6 | const reducer = (state = {testState: 'testState'}, action) => { 7 | actions.push(action) 8 | return state 9 | } 10 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 11 | 12 | dispatcher(store.dispatch) 13 | 14 | return actions 15 | } 16 | 17 | function getLastAction(config, options, testAction) { 18 | const actions = getActions(config, options, function (dispatch) { 19 | dispatch(testAction) 20 | }) 21 | return actions[actions.length - 1] 22 | } 23 | it('should throw an error', () => { 24 | expect(getLastAction).toThrow() 25 | }) 26 | 27 | it('should dispatch action', () => { 28 | const testAction = { type: 'TEST' } 29 | const options = { validate: false } 30 | const config = [] 31 | 32 | expect(getLastAction(config, options, testAction)) 33 | .toEqual(testAction) 34 | }) 35 | 36 | it('should not dispatch action', () => { 37 | const testAction = { type: 'TEST' } 38 | const options = { validate: true } 39 | const config = [] 40 | 41 | expect(getLastAction(config, options, testAction)) 42 | .not.toEqual(testAction) 43 | }) 44 | 45 | it('should transform actions', () => { 46 | const testAction = { type: 'TEST' } 47 | const options = { validate: true } 48 | const config = [ 49 | { 50 | case: 'TEST', 51 | dispatch: 'AFTER_ORCHESTRATION' 52 | } 53 | ] 54 | 55 | expect(getLastAction(config, options, testAction)) 56 | .toEqual({ type: 'AFTER_ORCHESTRATION' }) 57 | }) 58 | 59 | it('should transform actions - case array format', () => { 60 | const options = { validate: true } 61 | const config = [ 62 | { 63 | case: ['TEST', 'TEST2'], 64 | dispatch: 'AFTER_ORCHESTRATION' 65 | } 66 | ] 67 | 68 | expect(getLastAction(config, options, { type: 'TEST' })) 69 | .toEqual({ type: 'AFTER_ORCHESTRATION' }) 70 | 71 | expect(getLastAction(config, options, { type: 'TEST2' })) 72 | .toEqual({ type: 'AFTER_ORCHESTRATION' }) 73 | }) 74 | 75 | it('should transform actions - cascade', () => { 76 | const options = { validate: true } 77 | const config = [ 78 | { 79 | case: 'TEST', 80 | dispatch: 'AFTER_ORCHESTRATION' 81 | }, 82 | { 83 | case: 'AFTER_ORCHESTRATION', 84 | dispatch: 'AFTER_AFTER_ORCHESTRATION' 85 | } 86 | ] 87 | 88 | expect(getLastAction(config, options, { type: 'TEST' })) 89 | .toEqual({ type: 'AFTER_AFTER_ORCHESTRATION' }) 90 | }) 91 | 92 | it('should transform actions - dispatch object', () => { 93 | const options = { validate: true } 94 | const config = [ 95 | { 96 | case: ['TEST', 'TEST2'], 97 | dispatch: { type: 'AFTER_ORCHESTRATION' } 98 | } 99 | ] 100 | 101 | expect(getLastAction(config, options, { type: 'TEST' })) 102 | .toEqual({ type: 'AFTER_ORCHESTRATION' }) 103 | 104 | expect(getLastAction(config, options, { type: 'TEST2' })) 105 | .toEqual({ type: 'AFTER_ORCHESTRATION' }) 106 | }) 107 | 108 | it('should transform actions - dispatch function', () => { 109 | const testAction = { type: 'TEST' } 110 | const options = { validate: true } 111 | const config = [ 112 | { 113 | case: 'TEST', 114 | dispatch: (a, s) => `AFTER_ORCHESTRATION_${a.type}_${s.testState}` 115 | } 116 | ] 117 | 118 | expect(getLastAction(config, options, testAction)) 119 | .toEqual({ type: 'AFTER_ORCHESTRATION_TEST_testState' }) 120 | }) 121 | 122 | it('should transform actions - multiple rules', () => { 123 | const options = { validate: true } 124 | const config = [ 125 | { 126 | case: 'TEST', 127 | dispatch: 'AFTER_ORCHESTRATION' 128 | }, 129 | { 130 | case: 'TEST2', 131 | dispatch: 'AFTER_ORCHESTRATION2' 132 | } 133 | ] 134 | 135 | expect(getLastAction(config, options, { type: 'TEST' })) 136 | .toEqual({ type: 'AFTER_ORCHESTRATION' }) 137 | 138 | expect(getLastAction(config, options, { type: 'TEST2' })) 139 | .toEqual({ type: 'AFTER_ORCHESTRATION2' }) 140 | }) 141 | 142 | it('should transform actions - delay', (done) => { 143 | const options = { validate: true } 144 | const config = [ 145 | { 146 | case: 'TEST', 147 | dispatch: 'DEBOUNCED', 148 | delay: 50 149 | } 150 | ] 151 | 152 | const actions = [] 153 | const reducer = (state, action) => { 154 | actions.push(action) 155 | return state 156 | } 157 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 158 | store.dispatch({ type: 'TEST' }) 159 | 160 | if (actions.length !== 1) { 161 | done.fail(`expected 1 dispatched action, got ${actions.length}`) 162 | } 163 | 164 | setTimeout(() => { 165 | if (actions.length === 2) { 166 | done() 167 | } else { 168 | done.fail(`expected 2 dispatched actions, got ${actions.length}`) 169 | } 170 | }, 100) 171 | }) 172 | 173 | it('should transform actions - debounce', (done) => { 174 | const options = { validate: true } 175 | const config = [ 176 | { 177 | case: 'TEST', 178 | dispatch: 'DEBOUNCED', 179 | debounce: 50 180 | }, 181 | { 182 | case: 'TEST2', 183 | dispatch: 'DEBOUNCED', 184 | debounce: 50 185 | } 186 | ] 187 | 188 | const actions = [] 189 | const reducer = (state, action) => { 190 | actions.push(action) 191 | return state 192 | } 193 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 194 | 195 | store.dispatch({ type: 'TEST' }) 196 | store.dispatch({ type: 'TEST' }) 197 | store.dispatch({ type: 'TEST' }) 198 | store.dispatch({ type: 'TEST2' }) 199 | store.dispatch({ type: 'TEST2' }) 200 | store.dispatch({ type: 'TEST2' }) 201 | 202 | setTimeout(() => { 203 | const expectedNum = 3 204 | if (actions.length === expectedNum) { 205 | done() 206 | } else { 207 | done.fail(`expected ${expectedNum} dispatched actions, got ${actions.length}`) 208 | } 209 | }, 200) 210 | }) 211 | 212 | it('should transform actions - delay debounce', (done) => { 213 | const options = { validate: true } 214 | const config = [ 215 | { 216 | case: 'TEST', 217 | dispatch: 'DEBOUNCED', 218 | delay: 50, 219 | debounce: 50 220 | } 221 | ] 222 | 223 | const actions = [] 224 | const reducer = (state, action) => { 225 | actions.push(action) 226 | return state 227 | } 228 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 229 | 230 | store.dispatch({ type: 'TEST' }) 231 | store.dispatch({ type: 'TEST' }) 232 | store.dispatch({ type: 'TEST' }) 233 | store.dispatch({ type: 'TEST' }) 234 | store.dispatch({ type: 'TEST' }) 235 | 236 | if (actions.length !== 1) { 237 | done.fail(`expected 1 dispatched action, got ${actions.length}`) 238 | } 239 | 240 | setTimeout(() => { 241 | const expectedNum = 2 242 | if (actions.length === expectedNum) { 243 | done() 244 | } else { 245 | done.fail(`expected ${expectedNum} dispatched actions, got ${actions.length}`) 246 | } 247 | }, 110) 248 | }) 249 | 250 | it('should fail sending request', (done) => { 251 | const actions = [] 252 | const options = { validate: true } 253 | const config = [ 254 | { 255 | case: 'ADD_MESSAGE_REQUESTED', 256 | request: { 257 | url: 'https://non.existing.c', 258 | onSuccess: 'ADD_MESSAGE_SUCCEEDED', 259 | onFail: 'ADD_MESSAGE_FAILED', 260 | callback: () => { 261 | if (actions[1].type === 'ADD_MESSAGE_FAILED') { 262 | done() 263 | } 264 | } 265 | } 266 | } 267 | ] 268 | 269 | const reducer = (state, action) => { 270 | actions.push(action) 271 | return state 272 | } 273 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 274 | store.dispatch({ type: 'ADD_MESSAGE_REQUESTED' }) 275 | }) 276 | 277 | it('should fail sending request - onFail function', (done) => { 278 | const actions = [] 279 | const options = { validate: true } 280 | const config = [ 281 | { 282 | case: 'ADD_MESSAGE_REQUESTED', 283 | request: { 284 | url: 'https://non.existing.c', 285 | onSuccess: 'ADD_MESSAGE_SUCCEEDED', 286 | onFail: err => ({ type: 'ADD_MESSAGE_FAILED' }), 287 | callback: () => { 288 | if (actions[1].type === 'ADD_MESSAGE_FAILED') { 289 | done() 290 | } 291 | } 292 | } 293 | } 294 | ] 295 | 296 | const reducer = (state, action) => { 297 | actions.push(action) 298 | return state 299 | } 300 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 301 | store.dispatch({ type: 'ADD_MESSAGE_REQUESTED' }) 302 | }) 303 | 304 | it('should fail sending request - post', (done) => { 305 | const actions = [] 306 | const options = { validate: true } 307 | const config = [ 308 | { 309 | case: 'ADD_MESSAGE_REQUESTED', 310 | post: { 311 | url: 'https://non.existing.c', 312 | onSuccess: 'ADD_MESSAGE_SUCCEEDED', 313 | onFail: 'ADD_MESSAGE_FAILED', 314 | callback: (err) => { 315 | if (actions[1].type === 'ADD_MESSAGE_FAILED') { 316 | done() 317 | } 318 | } 319 | } 320 | } 321 | ] 322 | 323 | const reducer = (state, action) => { 324 | actions.push(action) 325 | return state 326 | } 327 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 328 | store.dispatch({ type: 'ADD_MESSAGE_REQUESTED' }) 329 | }) 330 | 331 | /* 332 | // commented because this test is depending on github api 333 | 334 | it('should send request', (done) => { 335 | const actions = [] 336 | const options = { validate: true } 337 | const config = [ 338 | { 339 | case: 'ADD_MESSAGE_REQUESTED', 340 | request: { 341 | url: 'https://api.github.com/users/test', 342 | onSuccess: res => ({ type: 'ADD_MESSAGE_SUCCEEDED', payload: res.data }), 343 | onFail: 'ADD_MESSAGE_FAILED', 344 | callback: () => { 345 | if (actions[1].type === 'ADD_MESSAGE_SUCCEEDED' && actions[1].payload.login === 'test') { 346 | done() 347 | } else { 348 | done.fail() 349 | } 350 | } 351 | } 352 | } 353 | ] 354 | 355 | const reducer = (state, action) => { 356 | actions.push(action) 357 | return state 358 | } 359 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 360 | store.dispatch({ type: 'ADD_MESSAGE_REQUESTED' }) 361 | }) 362 | */ 363 | /* 364 | it('should send request - onSuccess string', (done) => { 365 | // this test is depending on github api 366 | const actions = [] 367 | const options = { validate: true } 368 | const config = [ 369 | { 370 | case: 'ADD_MESSAGE_REQUESTED', 371 | request: { 372 | url: 'https://api.github.com/users/test', 373 | onSuccess: 'ADD_MESSAGE_SUCCEEDED', 374 | onFail: 'ADD_MESSAGE_FAILED', 375 | callback: () => { 376 | if (actions[1].type === 'ADD_MESSAGE_SUCCEEDED') { 377 | done() 378 | } else { 379 | done.fail() 380 | } 381 | } 382 | } 383 | } 384 | ] 385 | 386 | const reducer = (state, action) => { 387 | actions.push(action) 388 | return state 389 | } 390 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 391 | store.dispatch({ type: 'ADD_MESSAGE_REQUESTED' }) 392 | }) 393 | */ 394 | it('should transform actions - cascade debounce', (done) => { 395 | const options = { validate: true } 396 | const config = [ 397 | { 398 | case: [ 399 | 'CHAT_INPUT_SUBMITTED', 400 | 'MESSANGER_INPUT_SUBMITED' 401 | ], 402 | dispatch: 'ADD_MESSAGE_REQUESTED' 403 | }, 404 | { 405 | case: 'ADD_MESSAGE_REQUESTED', 406 | dispatch: 'SECOND', 407 | debounce: 100 408 | } 409 | ] 410 | 411 | const actions = [] 412 | const reducer = (state, action) => { 413 | actions.push(action) 414 | return state 415 | } 416 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 417 | store.dispatch({ type: 'CHAT_INPUT_SUBMITTED' }) 418 | store.dispatch({ type: 'MESSANGER_INPUT_SUBMITED' }) 419 | store.dispatch({ type: 'CHAT_INPUT_SUBMITTED' }) 420 | 421 | setTimeout(() => { 422 | if ( 423 | actions[1].type === 'ADD_MESSAGE_REQUESTED', 424 | actions[2].type === 'ADD_MESSAGE_REQUESTED', 425 | actions[3].type === 'ADD_MESSAGE_REQUESTED', 426 | actions[4].type === 'SECOND' 427 | ) { 428 | done() 429 | } else { 430 | done.fail() 431 | } 432 | }, 200) 433 | 434 | }) 435 | 436 | it('should fail sending request - debounce', (done) => { 437 | const actions = [] 438 | const options = { validate: true } 439 | const config = [ 440 | { 441 | case: [ 442 | 'CHAT_INPUT_SUBMITTED', 443 | 'MESSANGER_INPUT_SUBMITED' 444 | ], 445 | dispatch: 'ADD_MESSAGE_REQUESTED' 446 | }, 447 | { 448 | case: 'ADD_MESSAGE_REQUESTED', 449 | debounce: 200, 450 | request: { 451 | url: 'https://non.existing.c', 452 | onSuccess: 'ADD_MESSAGE_SUCCEEDED', 453 | onFail: 'ADD_MESSAGE_FAILED', 454 | callback: () => { 455 | if ( 456 | actions[1].type === 'ADD_MESSAGE_REQUESTED', 457 | actions[2].type === 'ADD_MESSAGE_REQUESTED', 458 | actions[3].type === 'ADD_MESSAGE_REQUESTED', 459 | actions[4].type === 'ADD_MESSAGE_FAILED' 460 | ) { 461 | done() 462 | } else { 463 | done.fail() 464 | } 465 | } 466 | } 467 | } 468 | ] 469 | 470 | const reducer = (state, action) => { 471 | actions.push(action) 472 | return state 473 | } 474 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 475 | store.dispatch({ type: 'CHAT_INPUT_SUBMITTED' }) 476 | store.dispatch({ type: 'MESSANGER_INPUT_SUBMITED' }) 477 | store.dispatch({ type: 'CHAT_INPUT_SUBMITTED' }) 478 | }) 479 | 480 | it('should cancel sending request', (done) => { 481 | const actions = [] 482 | const options = { validate: true } 483 | const config = [ 484 | { 485 | case: 'ADD_MESSAGE_REQUESTED', 486 | request: { 487 | url: 'https://jsonplaceholder.typicode.com/posts/1', 488 | onFail: 'ON_FAIL', 489 | onSuccess: 'ON_SUCCESS', 490 | cancelWhen: [ 491 | 'CANCEL_EVENT', 492 | 'CANCEL_EVENT_SECOND' 493 | ], 494 | callback: (err) => { 495 | if (err.message.type === 'CANCEL_EVENT') { 496 | done() 497 | } else { 498 | done.fail() 499 | } 500 | } 501 | } 502 | } 503 | ] 504 | 505 | const reducer = (state, action) => { 506 | actions.push(action) 507 | return state 508 | } 509 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 510 | store.dispatch({ type: 'ADD_MESSAGE_REQUESTED' }) 511 | 512 | setTimeout(() => { 513 | store.dispatch({ type: 'CANCEL_EVENT' }) 514 | }, 0) 515 | }) 516 | 517 | it('should dispatch multiple actions', (done) => { 518 | const options = { validate: true } 519 | const config = [ 520 | { 521 | case: 'TEST', 522 | dispatch: ['FIRST', 'SECOND'] 523 | } 524 | ] 525 | 526 | const actions = [] 527 | const reducer = (state, action) => { 528 | actions.push(action) 529 | return state 530 | } 531 | const store = createStore(reducer, applyMiddleware(orchestrate(config, options))) 532 | store.dispatch({ type: 'TEST' }) 533 | 534 | setTimeout(() => { 535 | if (actions.length === 3) { 536 | done() 537 | } else { 538 | done.fail() 539 | } 540 | }, 100) 541 | }) 542 | 543 | it('should dispatch added actions from dynamically added rules', (done) => { 544 | const options = { validate: true } 545 | const rules = [{ 546 | case: 'TEST', 547 | dispatch: ['FIRST', 'SECOND'] 548 | }] 549 | 550 | const actions = [] 551 | const reducer = (state, action) => { 552 | actions.push(action) 553 | return state 554 | } 555 | const processManager = orchestrate([], options) 556 | const store = createStore(reducer, applyMiddleware(processManager)) 557 | 558 | processManager.addRules(rules) 559 | store.dispatch({ type: 'TEST' }) 560 | 561 | setTimeout(() => { 562 | if (actions.length === 3) { 563 | done() 564 | } else { 565 | done.fail() 566 | } 567 | }, 100) 568 | }) 569 | 570 | -------------------------------------------------------------------------------- /src/timeTransform.js: -------------------------------------------------------------------------------- 1 | function timeTransform (action, options, callback) { 2 | const { debounce, delay, _debounceTimeoutRefs } = options 3 | let cb = callback 4 | if (delay) { 5 | cb = (action) => setTimeout(() => callback(), delay) 6 | } 7 | 8 | if (debounce) { 9 | clearTimeout(_debounceTimeoutRefs[action.type]) 10 | _debounceTimeoutRefs[action.type] = setTimeout(() => { 11 | delete _debounceTimeoutRefs[action.type] 12 | cb() 13 | }, debounce) 14 | 15 | } else { 16 | cb() 17 | } 18 | } 19 | 20 | export default timeTransform 21 | -------------------------------------------------------------------------------- /src/timeTransform.test.js: -------------------------------------------------------------------------------- 1 | import timeTransform from './timeTransform' 2 | 3 | it('should invoke callback', (done) => { 4 | const action = { type: 'TEST_ACTION' } 5 | const options = {} 6 | 7 | timeTransform(action, options, function () { 8 | done() 9 | }) 10 | }) 11 | 12 | it('should invoke callback synchonously', (done) => { 13 | const action = { type: 'TEST_ACTION' } 14 | const options = {} 15 | 16 | let async = false 17 | timeTransform(action, options, function () { 18 | if (async === false) { 19 | done() 20 | } else { 21 | done.fail('callback not synchronous') 22 | } 23 | }) 24 | async = true 25 | }) 26 | 27 | it('should invoke callback asynchronously (delay)', (done) => { 28 | const action = { type: 'TEST_ACTION' } 29 | const options = { 30 | delay: 100 31 | } 32 | 33 | let async = false 34 | timeTransform(action, options, function () { 35 | if (async) { 36 | done() 37 | } else { 38 | done.fail('callback not asynchronous') 39 | } 40 | }) 41 | async = true 42 | }) 43 | 44 | it('should not debounce calls', (done) => { 45 | const action = { type: 'TEST_ACTION' } 46 | const options = { } 47 | 48 | let called = 0 49 | timeTransform(action, options, function () { 50 | called++ 51 | }) 52 | timeTransform(action, options, function () { 53 | called++ 54 | }) 55 | timeTransform(action, options, function () { 56 | called++ 57 | }) 58 | timeTransform(action, options, function () { 59 | called++ 60 | }) 61 | 62 | setTimeout(function () { 63 | if (called === 4) { 64 | done() 65 | } else { 66 | done.fail() 67 | } 68 | }, 0) 69 | }) 70 | 71 | it('should debounce calls', (done) => { 72 | const action = { type: 'TEST_ACTION' } 73 | const options = { 74 | debounce: 100, 75 | _debounceTimeoutRefs: {} 76 | } 77 | 78 | let called = 0 79 | timeTransform(action, options, function () { 80 | called++ 81 | }) 82 | timeTransform(action, options, function () { 83 | called++ 84 | }) 85 | timeTransform(action, options, function () { 86 | called++ 87 | }) 88 | timeTransform(action, options, function () { 89 | called++ 90 | }) 91 | 92 | setTimeout(function () { 93 | if (called === 1) { 94 | done() 95 | } else { 96 | done.fail() 97 | } 98 | }, 200) 99 | }) 100 | --------------------------------------------------------------------------------