├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── package.json └── src ├── __tests__ ├── helpers.js ├── index.js └── real-world.example.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | env: { es6: true }, 3 | parser: "babel-eslint", 4 | plugins: ["react"], 5 | extends: [ "standard", "standard-react" ], 6 | globals: { 7 | $: false, 8 | jest: false, 9 | describe: false, 10 | pit: false, 11 | it: false, 12 | expect: false, 13 | beforeEach: false, 14 | jasmine: false, 15 | document: false, 16 | __DEBUG__: false, 17 | }, 18 | rules: { 19 | "object-curly-spacing": ["error", "always"], 20 | "comma-dangle": 0, 21 | "no-throw-literal": 1, 22 | "strict": [2, "never"], 23 | "semi": [2, "never"], 24 | "quotes": [2, "single"], 25 | "no-var": 1, 26 | "brace-style": [2, "stroustrup"], 27 | "eol-last": 1, 28 | "no-undef": "error", 29 | "no-new-require": 1, 30 | "no-sync": 1, 31 | "no-mixed-requires": [1, false], 32 | "react/wrap-multilines": 2, 33 | "no-unsafe-finally": 0, 34 | "no-useless-computed-key": 0, 35 | 36 | "react/jsx-uses-react": 1, 37 | "react/jsx-no-undef": 2, 38 | "react/jsx-sort-props": 0, 39 | "react/jsx-sort-prop-types": 0, 40 | "react/jsx-curly-spacing": ["error", "always"] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node specific # 2 | ################# 3 | # Ignore node modules. 4 | /node_modules 5 | 6 | # Builds # 7 | ########## 8 | /dist 9 | 10 | # Documentation # 11 | ################# 12 | /docs/_book 13 | 14 | # Packages # 15 | ############ 16 | *.7z 17 | *.dmg 18 | *.gz 19 | *.iso 20 | *.jar 21 | *.rar 22 | *.tar 23 | *.zip 24 | *.svn 25 | 26 | # OS generated files # 27 | ###################### 28 | .DS_Store* 29 | *.Trash* 30 | ehthumbs.db 31 | Icon? 32 | Thumbs.db 33 | *.orig 34 | *.swo 35 | *cwatch.pid 36 | 37 | # Editors generated files # 38 | ########################### 39 | .project 40 | .settings 41 | *~ 42 | *.sublime-project 43 | *.sublime-workspace 44 | *.sublime-projectcompletions 45 | nbproject 46 | .buildpath 47 | .idea 48 | .idea/* 49 | *.log 50 | 51 | # Compiled source # 52 | ################### 53 | *.com 54 | *.class 55 | *.dll 56 | *.exe 57 | *.o 58 | *.so 59 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | install: 5 | - npm install 6 | - npm run install:peers 7 | deploy: 8 | provider: npm 9 | email: choko@choko.org 10 | api_key: 11 | secure: Tg09+e0/z4P0NcVWeN3r4F5DmFGyYC/Z6fNYJTVXgvIEXyBwrzBmj+WjlWUdTpPSaKW9qWle0tVbcC35UAueV02sxelNrRxaN1W0Afelb9L8tTfs6v1TvbhQPw57x+Z7NyX7bBOezvLJOmsnG8pX8W6Wu4r3HZBU8lRlQtEvVZ6IC6GpA5ytzTdRs4W89caqxOWz/QjEqp8Bj43XChUy8w+/g0apukRtm01NHfJbAe7qHztqduaN2IlojhtyI6OKb43opNOZQWcR2wqgCPGVEhdR5lOZ69FgOV7D0t6iRH8W0f1fF7KT59xK8eVtOdCC7Gi52/axGQ12IwhqY6M8xgSA+7jWXXj0/LV52q51IDf1SUVNsEQP5sxkGFJWorquzK4hw6LWRdTna+wBVlma+vujYPKohloPK6etgvnSTwI7+87KWnI3vSJ1heHuSt8r4wcms2t015td30du5LZexzyWgvlF8GwS63AO4/QccVFbNAyJF+6/K8FqeaztXHBeUYgEXL+bKMDmuJLmWjZRO7SWXaK31GBfzzhZyP7qFn0zbJva3pbBZ99HklpvzHJeUjgQAOV08ibLbMfYIUHsnMc5f3i0gCp79efZ+1OzoVc/Y+qSM15Fkqyzx3Uowpj7vYnTn4cZG/GPaHT22fhBmVJg3ELB8ncOpZAR4dQev8E= 12 | on: 13 | tags: true 14 | repo: choko-org/redux-rules 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present, Sebastian Ferrari, Henrique Recidive 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 | # Rules API for [Redux](http://redux.js.org) 2 | Make your code more easy to reason about with a more **natural language for your 3 | logic**, using rules fired by actions and reacting to a given set of facts. 4 | 5 | Based on the forward-chaining rules in Clojure called [Clara](https://github.com/rbrush/clara-rules) and a discussion from an [issue](https://github.com/choko-org/choko-core/issues/1) 6 | of Choko core. 7 | 8 | [![Build Status](https://travis-ci.org/choko-org/redux-boot.svg?branch=master)](https://travis-ci.org/choko-org/redux-boot) 9 | 10 | 11 | ### Where Redux gets in? 12 | ``` 13 | [ACTION] => ([RULES] -> [CONDITIONS] -> [REACTIONS]) => [STATE] 14 | ``` 15 | *Obs.: Reactions are dispatched actions / side-effects.* 16 | 17 | Redux gives us the tools to build functional rules systems using it's middleware API. 18 | So basically each rule behaves like a Redux's middleware. 19 | 20 | ## Vanilla Redux: 21 | ```js 22 | import { createStore, applyMiddleware} from 'redux' 23 | import { flashMessage, FLASH_MESSAGE } from 'app/modules/messages/actions' 24 | 25 | const LOGIN_SUCCESS = 'user/login/SUCCESS' 26 | 27 | const welcomeAuthUserMiddleware = store => next => action => { 28 | switch (action.type) { 29 | case LOGIN_SUCCESS: 30 | if (action.payload.user.roles.some(role => role === 'authenticated')) { 31 | const { name, lastLogin } = action.payload.user 32 | if (!!lastLogin) { 33 | store.dispatch(flashMessage('Good to see you ' + name + '!')) 34 | } 35 | } 36 | break; 37 | } 38 | return next(action) 39 | } 40 | 41 | const reducer = (state, action) => { 42 | if (action.type === LOGIN_SUCCESS) { 43 | return { ...state, user: action.payload.user } 44 | } 45 | 46 | if (action.type === FLASH_MESSAGE) { 47 | return { ...state, message: action.payload } 48 | } 49 | } 50 | 51 | const store = createStore(reducer, applyMiddleware(welcomeAuthUserMiddleware)) 52 | 53 | store.dispatch({ 54 | type: LOGIN_SUCCESS, 55 | payload: { user: { name: 'Manolo', roles: ['authenticated'] } } 56 | }) 57 | 58 | console.log(getState().message) // Good to see you Manolo! 59 | ``` 60 | 61 | ## Using Redux Rules: 62 | ```js 63 | import { createStore, applyMiddleware } from 'redux' 64 | import combineRules, { every } from 'redux-rules' 65 | import { flashMessage, FLASH_MESSAGE } from 'app/modules/messages/actions' 66 | 67 | const LOGIN_SUCCESS = 'user/login/SUCCESS' 68 | 69 | const isAuthUser = ({ action }) => action.payload 70 | .user.roles.some(role => role === 'authenticated') 71 | const isComingBackUser = ({ action }) => !!action.payload.user.lastLogin 72 | 73 | const welcomeAuthUserMessageRule = { 74 | type: 'messages/user/login/SUCCESS', 75 | actionTypes: [LOGIN_SUCCESS], 76 | condition: every([ 77 | isAuthUser, isComingBackUser 78 | ]), 79 | reaction: store => next => action => { 80 | const { name } = action.payload.user 81 | store.dispatch(flashMessage('Good to see you ' + name + '!')) 82 | return next(action) 83 | } 84 | } 85 | 86 | const rulesMiddleware = combineRules({ 87 | rules: [welcomeAuthUserMessageRule] 88 | }) 89 | 90 | const reducer = (state, action) => { 91 | if (action.type === LOGIN_SUCCESS) { 92 | return { ...state, user: action.payload.user } 93 | } 94 | 95 | if (action.type === FLASH_MESSAGE) { 96 | return { ...state, message: action.payload } 97 | } 98 | } 99 | 100 | const store = createStore(reducer, applyMiddleware(rulesMiddleware)) 101 | 102 | store.dispatch({ 103 | type: LOGIN_SUCCESS, 104 | payload: { user: { name: 'Manolo', roles: ['authenticated'] } } 105 | }) 106 | 107 | console.log(getState().message) // Good to see you Manolo! 108 | ``` 109 | 110 | ## Usage: 111 | - [Tests](https://github.com/choko-org/redux-rules/tree/master/src/__tests__). 112 | - [Real World](https://github.com/choko-org/redux-rules/blob/master/src/__tests__/real-world.example.js). 113 | - Docs coming soon... 114 | 115 | # LICENSE 116 | [MIT](LICENSE.md) 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-rules", 3 | "version": "0.0.7", 4 | "description": "Rules API for Redux.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "install:peers": "npm info \"$PKG\" peerDependencies --json | command sed 's/[\\{\\},]//g ; s/: /@/g' | xargs npm install \"$PKG\"", 8 | "build": "npm run build:commonjs", 9 | "build:commonjs": "babel src --out-dir dist --ignore __tests__/*", 10 | "test": "tape -r babel-register src/**/__tests__/**/*.js | tap-diff", 11 | "test:watch": "tape-watch -r babel-register src/**/__tests__/**/*.js -p tap-diff", 12 | "clean": "rimraf lib" 13 | }, 14 | "keywords": [ 15 | "redux", 16 | "rules" 17 | ], 18 | "author": "", 19 | "license": "MIT", 20 | "repository": "choko-org/redux-rules", 21 | "devDependencies": { 22 | "babel-cli": "^6.16.0", 23 | "babel-core": "^6.17.0", 24 | "babel-preset-es2015": "^6.16.0", 25 | "babel-preset-stage-0": "^6.16.0", 26 | "babel-register": "^6.16.3", 27 | "eslint": "^3.0.1", 28 | "eslint-config-rackt": "^1.1.1", 29 | "eslint-config-standard": "^5.3.1", 30 | "eslint-plugin-promise": "^1.3.2", 31 | "eslint-plugin-standard": "^1.3.2", 32 | "rimraf": "^2.5.4", 33 | "tap-diff": "^0.1.1", 34 | "tap-spec": "^4.1.1", 35 | "tape": "^4.6.2", 36 | "tape-watch": "^2.2.3" 37 | }, 38 | "peerDependencies": { 39 | "redux": "^3.6.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/__tests__/helpers.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import { 3 | byTruthyCondition, 4 | byActionType, 5 | every, 6 | some, 7 | } from '../index' 8 | 9 | test('Helpers: should filter by action type', assert => { 10 | const LOGIN = 'LOGIN' 11 | const LOGOUT = 'LOGOUT' 12 | const REGISTRATE = 'REGISTRATE' 13 | const FLASH_MESSAGE = 'FLASH_MESSAGE' 14 | const SHOW_BANNER = 'SHOW_BANNER' 15 | const HIDE_BANNER = 'HIDE_BANNER' 16 | 17 | const mockRules = [ 18 | { actionTypes: [LOGIN, REGISTRATE], type: FLASH_MESSAGE }, 19 | { actionTypes: [LOGOUT], type: SHOW_BANNER }, 20 | { actionTypes: [LOGIN], type: HIDE_BANNER }, 21 | ] 22 | 23 | const loginRules = mockRules.filter(byActionType(LOGIN)) 24 | 25 | assert.isEqual(loginRules.length, 2) 26 | assert.isEqual(loginRules[0].type, FLASH_MESSAGE) 27 | assert.isEqual(loginRules[1].type, HIDE_BANNER) 28 | assert.end() 29 | }) 30 | 31 | test('Helpers: should filter rules with truthy conditions', assert => { 32 | const FLASH_MESSAGE = 'FLASH_MESSAGE' 33 | const SHOW_BANNER = 'SHOW_BANNER' 34 | const HIDE_BANNER = 'HIDE_BANNER' 35 | 36 | const mockRules = [ 37 | { condition: facts => facts.name === 'Manolo', type: FLASH_MESSAGE }, 38 | { condition: facts => facts.name === 'Isaac', type: SHOW_BANNER }, 39 | { condition: facts => facts.name === 'Manolo', type: HIDE_BANNER }, 40 | ] 41 | 42 | const mockFacts = { name: 'Manolo' } 43 | const loginRules = mockRules.filter(byTruthyCondition(mockFacts)) 44 | 45 | assert.isEqual(loginRules.length, 2) 46 | assert.isEqual(loginRules[0].type, FLASH_MESSAGE) 47 | assert.isEqual(loginRules[1].type, HIDE_BANNER) 48 | assert.end() 49 | }) 50 | 51 | test('Helpers: every conditions', assert => { 52 | const facts = { 53 | user: { 54 | likes: ['skate', 'travel', 'beach'], 55 | location: 'Floripa, Brasil' 56 | }, 57 | } 58 | 59 | const likesBeach = ({ user: { likes } }) => likes.some(like => like === 'beach') 60 | const likesTravel = ({ user: { likes } }) => likes.some(like => like === 'travel') 61 | const locatesInFloripa = ({ user: { location } }) => location.includes('Floripa') 62 | 63 | const condition = every([likesBeach, likesTravel, locatesInFloripa]) 64 | 65 | assert.ok(condition(facts), 'All conditions are true') 66 | assert.end() 67 | }) 68 | 69 | test('Helpers: some conditions', assert => { 70 | const facts = { 71 | user: { 72 | likes: ['skate', 'travel', 'beach'], 73 | location: 'Florianópolis, Brasil' 74 | }, 75 | } 76 | 77 | const likesBeach = ({ user: { likes } }) => likes.some(like => like === 'beach') 78 | const likesTravel = ({ user: { likes } }) => likes.some(like => like === 'travel') 79 | const locatesInSaoPaulo = ({ user: { location } }) => location.includes('São Paulo') 80 | 81 | const condition = some([likesBeach, likesTravel, locatesInSaoPaulo]) 82 | 83 | assert.ok(condition(facts), 'Some of the conditions are true') 84 | assert.end() 85 | }) 86 | 87 | test('Helpers: should be possible to combine every and some operators', assert => { 88 | const facts = { 89 | user: { 90 | likes: ['skate', 'travel', 'beach'], 91 | location: 'Montevideo, Uruguay' 92 | }, 93 | } 94 | 95 | const likesBeach = ({ user: { likes } }) => likes.some(like => like === 'beach') 96 | const likesTravel = ({ user: { likes } }) => likes.some(like => like === 'travel') 97 | const locatesInBrasil = ({ user: { location } }) => location.includes('Brasil') 98 | const locatesInUruguay = ({ user: { location } }) => location.includes('Uruguay') 99 | 100 | const truthyCondition = every([ 101 | likesBeach, 102 | likesTravel, 103 | some([locatesInBrasil, locatesInUruguay]) 104 | ]) 105 | assert.ok(truthyCondition(facts), 'Result of combined conditions is true') 106 | 107 | const falsyCondition = every([ 108 | likesBeach, 109 | likesTravel, 110 | every([locatesInBrasil, locatesInUruguay]) 111 | ]) 112 | assert.notOk(falsyCondition(facts), 'Result of combined conditions is false') 113 | 114 | assert.end() 115 | }) 116 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import { createStore, applyMiddleware } from 'redux' 3 | import combineRules from '../index' 4 | 5 | test('Rules should have a basic structure', assert => { 6 | const LOGIN_SUCCESS = 'users/login/SUCESS' 7 | const WELCOME_MESSAGE = 'users/login/WELCOME_MESSAGE' 8 | 9 | const welcomeMessageRule = { 10 | type: WELCOME_MESSAGE, 11 | actionTypes: [ LOGIN_SUCCESS ], 12 | condition: facts => true, 13 | reaction: store => next => action => next(action) 14 | } 15 | 16 | const { condition, ...ruleWithoutCondition } = welcomeMessageRule 17 | assert.throws( 18 | () => combineRules({ rules: [ruleWithoutCondition] }), 19 | /WELCOME_MESSAGE/ 20 | ) 21 | assert.end() 22 | }) 23 | 24 | test('Rules should react and dispatch a new action', assert => { 25 | const LOGIN_SUCCESS = 'users/login/SUCESS' 26 | const FLASH_MESSAGE = 'system/FLASH_MESSAGE' 27 | const WELCOME_MESSAGE = 'users/login/WELCOME_MESSAGE' 28 | 29 | const userIsAdminCondition = facts => facts.action.payload 30 | .user.roles.some(role => role === 'admin') 31 | 32 | const welcomeMessageRule = { 33 | type: WELCOME_MESSAGE, 34 | actionTypes: [ LOGIN_SUCCESS ], 35 | condition: userIsAdminCondition, 36 | reaction: ({ getState, dispatch }) => next => action => { 37 | const nextResult = next(action) 38 | 39 | const userName = getState().user.name 40 | 41 | dispatch({ 42 | type: FLASH_MESSAGE, 43 | payload: 'Hello ' + userName + '!' 44 | }) 45 | 46 | return nextResult 47 | } 48 | } 49 | 50 | // Here's the magic. 51 | const mockMiddlewareWithRule = combineRules({ rules: [welcomeMessageRule] }) 52 | 53 | const mockMiddlewares = [mockMiddlewareWithRule] 54 | 55 | // @TODO: Create a redux store with the middleware. 56 | const reducer = (state = {}, action) => { 57 | if (action.type === LOGIN_SUCCESS) { 58 | return { ...state, user: action.payload.user } 59 | } 60 | 61 | if (action.type === FLASH_MESSAGE) { 62 | return { ...state, message: action.payload } 63 | } 64 | 65 | return state 66 | } 67 | 68 | const store = createStore( 69 | reducer, 70 | applyMiddleware(...mockMiddlewares) 71 | ) 72 | 73 | store.dispatch({ 74 | type: LOGIN_SUCCESS, 75 | payload: { 76 | user: { 77 | name: 'Manolo', 78 | roles: ['admin', 'authenticated'] 79 | } 80 | } 81 | }) 82 | 83 | assert.isEqual(store.getState().message, 'Hello Manolo!') 84 | assert.end() 85 | }) 86 | -------------------------------------------------------------------------------- /src/__tests__/real-world.example.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import { createStore, applyMiddleware } from 'redux' 3 | import combineRules, { every, notEvery } from '../index' 4 | 5 | // Actions. 6 | 7 | const CREATE_ORDER = 'shopping/CREATE_ORDER' 8 | const createOrderAction = order => ({ 9 | type: CREATE_ORDER, 10 | payload: { order } 11 | }) 12 | 13 | const FLASH_MESSAGE = 'system/FLASH_MESSAGE' 14 | const flashMessageAction = ({ text }) => ({ 15 | type: FLASH_MESSAGE, 16 | payload: { text } 17 | }) 18 | 19 | // Redux Reducers. 20 | const mockReducerWithInitialState = (initialState = {}) => (state = initialState, action) => { 21 | if (action.type === CREATE_ORDER) { 22 | return { ...state, order: action.payload.order } 23 | } 24 | 25 | if (action.type === FLASH_MESSAGE) { 26 | const message = action.payload.text 27 | const messages = state.messages.concat(message) 28 | return { ...state, messages } 29 | } 30 | 31 | return state 32 | } 33 | 34 | // Discounts Rules. 35 | 36 | const orderIsOnSummer = ({ action: { payload } }) => ['june', 'july', 'august'] 37 | .some(month => month === payload.order.month) 38 | 39 | const summerSpecialDiscountRule = { 40 | type: 'discounts/SUMMER_SPECIAL', 41 | actionTypes: [CREATE_ORDER], 42 | condition: orderIsOnSummer, 43 | reaction: (store) => next => action => { 44 | const nextResult = next(action) 45 | store.dispatch(flashMessageAction({ 46 | text: 'You get 20% of discount, because is Summer!' 47 | })) 48 | return nextResult 49 | } 50 | } 51 | 52 | const customerIsVip = ({ state: { customer } }) => customer.roles 53 | .some(role => role === 'vip') 54 | const customerIsAdmin = ({ state: { customer } }) => customer.roles 55 | .some(role => role === 'admin') 56 | 57 | const vipCustomersDiscountRule = { 58 | type: 'discounts/VIP', 59 | actionTypes: [CREATE_ORDER], 60 | condition: every([ 61 | ({ state }) => state.total > 100, 62 | notEvery([customerIsVip, customerIsAdmin]), 63 | ]), 64 | reaction: (store) => next => action => { 65 | const nextResult = next(action) 66 | store.dispatch(flashMessageAction({ 67 | text: 'VIP gets 10% when total is up to $100 !' 68 | })) 69 | return nextResult 70 | } 71 | } 72 | 73 | test('Example: Shopping as an VIP customer in Summer', assert => { 74 | const initialState = { 75 | customer: { roles: ['vip', 'authenticated'] }, 76 | order: {}, 77 | purchases: [ 78 | { item: 'gizmo', value: '20' }, 79 | { item: 'widget', value: '120' }, 80 | ], 81 | messages: [], 82 | total: 140, 83 | } 84 | 85 | const mockedReducer = mockReducerWithInitialState(initialState) 86 | 87 | // Combine Rules, into middlewares. 88 | 89 | const mockedMiddlewareWithRule = combineRules({ 90 | rules: [vipCustomersDiscountRule, summerSpecialDiscountRule] 91 | }) 92 | 93 | // Redux Store. 94 | 95 | const store = createStore( 96 | mockedReducer, 97 | applyMiddleware(mockedMiddlewareWithRule) 98 | ) 99 | 100 | store.dispatch(createOrderAction({ month: 'august' })) 101 | assert.isEqual(store.getState().messages[0], 'You get 20% of discount, because is Summer!') 102 | assert.isEqual(store.getState().messages[1], 'VIP gets 10% when total is up to $100 !') 103 | 104 | assert.end() 105 | }) 106 | 107 | test('Example: Shopping as an Admin customer in Summer', assert => { 108 | const initialState = { 109 | customer: { roles: ['vip', 'authenticated', 'admin'] }, 110 | order: {}, 111 | purchases: [ 112 | { item: 'gizmo', value: '20' }, 113 | { item: 'widget', value: '120' }, 114 | ], 115 | messages: [], 116 | total: 140, 117 | } 118 | 119 | const mockedReducer = mockReducerWithInitialState(initialState) 120 | 121 | // Combine Rules, into middlewares. 122 | 123 | const mockedMiddlewareWithRule = combineRules({ 124 | rules: [vipCustomersDiscountRule, summerSpecialDiscountRule] 125 | }) 126 | 127 | // Redux Store. 128 | 129 | const store = createStore( 130 | mockedReducer, 131 | applyMiddleware(mockedMiddlewareWithRule) 132 | ) 133 | 134 | store.dispatch(createOrderAction({ month: 'july' })) 135 | assert.isEqual(store.getState().messages.length, 1) 136 | assert.isEqual(store.getState().messages[0], 'You get 20% of discount, because is Summer!') 137 | 138 | assert.end() 139 | }) 140 | 141 | test('Example: Shopping as an Vip customer not in Summer', assert => { 142 | const initialState = { 143 | customer: { roles: ['vip', 'authenticated', 'admin'] }, 144 | order: {}, 145 | purchases: [ 146 | { item: 'gizmo', value: '20' }, 147 | { item: 'widget', value: '40' }, 148 | ], 149 | messages: [], 150 | total: 60, 151 | } 152 | 153 | const mockedReducer = mockReducerWithInitialState(initialState) 154 | 155 | // Combine Rules, into middlewares. 156 | 157 | const mockedMiddlewareWithRule = combineRules({ 158 | rules: [vipCustomersDiscountRule, summerSpecialDiscountRule] 159 | }) 160 | 161 | // Redux Store. 162 | 163 | const store = createStore( 164 | mockedReducer, 165 | applyMiddleware(mockedMiddlewareWithRule) 166 | ) 167 | 168 | store.dispatch(createOrderAction({ month: 'april' })) 169 | assert.isNotEqual(store.getState().messages.length, 1) 170 | 171 | assert.end() 172 | }) 173 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { compose } from 'redux' 2 | 3 | const combineRules = ({ rules = [] }) => { 4 | const verifiedRules = rules.filter(verifyStructure) 5 | 6 | return store => next => action => { 7 | const { getState } = store 8 | const truthyReactions = verifiedRules 9 | .filter(byActionType(action.type)) 10 | .filter(byTruthyCondition({ state: getState(), action })) 11 | .map(rule => rule.reaction(store)) 12 | 13 | if (truthyReactions.length === 0) return next(action) 14 | 15 | // @TODO Dispatch action with the type of each truthy Rule. 16 | 17 | return compose(...truthyReactions)(next)(action) 18 | } 19 | } 20 | 21 | const verifyStructure = rule => { 22 | const { type, condition, actionTypes, reaction } = rule 23 | 24 | // @TODO: Should be better ways of doing schema check. 25 | const result = (typeof type === 'string') && 26 | Array.isArray(actionTypes) && 27 | (typeof condition === 'function') && 28 | (typeof reaction === 'function') 29 | 30 | if (result === false) { 31 | throw new TypeError('Rule "' + type + '" miss a property.') 32 | } 33 | 34 | return result 35 | } 36 | 37 | export const byTruthyCondition = facts => rule => rule.condition(facts) 38 | 39 | export const byActionType = actionType => rule => rule.actionTypes 40 | .some(type => type === actionType) 41 | 42 | export const every = conditions => facts => conditions.every(condition => condition(facts)) 43 | export const some = conditions => facts => conditions.some(condition => condition(facts)) 44 | export const notEvery = conditions => facts => !every(conditions)(facts) 45 | export const notSome = conditions => facts => !some(conditions)(facts) 46 | 47 | export default combineRules 48 | --------------------------------------------------------------------------------