├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── devtools-screenshot.png ├── package.json ├── src └── index.js └── test └── spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["es2015"] } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | npm-debug.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | devtools-screenshot.png -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Leon Aves 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 Cut 2 | Middleware to block redux actions based on provided criteria. 3 | 4 | ## What's it for? 5 | Redux Cut is middleware for the [Redux framework](https://github.com/reactjs/redux) which enables you to block certain actions from firing, based on certain criteria. 6 | 7 | The middleware intercepts these actions and instead dispatches a "blocked action" action which you can listen for in your reducer using `isBlockedAction`. 8 | 9 | The new action includes the original action as its payload. You can use this to help with the implementation of protected functionality, permissions, or simply as a way to group this sort of functionality which you may have had in your reducers. 10 | 11 | ### Can't I do this kind of thing with [insert some other library]? 12 | Definitely. Redux Cut just provides a certain way of going about things that you may prefer to other methods. 13 | 14 | ## How do I use it? 15 | 16 | You can install Redux Cut through NPM: 17 | 18 | ``` 19 | npm install redux-cut --save 20 | ``` 21 | 22 | Import it: 23 | 24 | ```js 25 | import cut from 'redux-cut'; // Or your module import syntax of choice 26 | ``` 27 | 28 | Then apply it to your store like so: 29 | 30 | ```js 31 | let store = createStore(reducer, initialState, applyMiddleware(cut(criteria), ...otherMiddlewares)); 32 | ``` 33 | 34 | You'll notice that the imported `cut` function takes a single argument which then returns the middleware itself. This function (the criteria function), is one which you must provide, which determines whether actions are allowed to be dispatched. The function is passed two arguments, the current state of your store, and the action dispatched. 35 | 36 | This signature will feel incredibly familiar to you if you if you have used Redux's reducers (which presumably you have or you probably wouldn't be reading this); the only difference is that instead of returning a new version of the state, you return `false` if the action is not permitted. You may return `true` to allow the function to be dispatched, but you may also return nothing at all, the middleware doesn't care about the return value of this function unless it is false. Let's look at an example: 37 | 38 | ```js 39 | const permitActions = (state, action) => { 40 | switch(action.type) { 41 | case 'DELETE_ALL_CUSTOMER_INFORMATION': 42 | return 'manager' === state.currentUser.role; 43 | case 'INSERT_NEW_RECORD': 44 | if (state.records.entries.size() >= state.records.maxSize) { 45 | return false; 46 | } 47 | } 48 | }; 49 | 50 | export default permitActions; 51 | ``` 52 | 53 | Obviously Redux Cut isn't meant to be a replacement for a real server validated permissions system (someone trying to delete all records could probably work out how to set their role to manager through the console), it can be handy to provide some immediate user feedback in cases where permissions might be denied at the server level. You may also use it for soft validation on non-destructive actions like attempting to exceed the maximum size of some data store. I'm sure you could come up with other uses too. 54 | 55 | ### Combining Criteria 56 | Much like Redux's reducers, you can also combine multiple criteria functions into a single function using the provided `combineCriteria` function: 57 | 58 | #### `action-criteria/index.js` 59 | ```js 60 | import { combineCriteria } from 'redux-cut' 61 | import playlistCriteria from './playlist' 62 | import songCriteria from './song' 63 | 64 | export default combineCriteria({ 65 | playlistCriteria, 66 | songCriteria 67 | }) 68 | ``` 69 | 70 | #### `App.js` 71 | ```js 72 | import criteria from './action-criteria/index'; 73 | 74 | let store = createStore(reducer, initialState, applyMiddleware(cut(criteria), ...otherMiddlewares)); 75 | ``` 76 | 77 | ### Listening for blocked actions 78 | You can listen for blocked actions by importing the avilable `isBlockedAction` function: 79 | 80 | ```js 81 | import { isBlockedAction } from '../index'; 82 | 83 | const initialState = { modalVisible: false }; 84 | 85 | const modalReducer = (state = initialState, action) => { 86 | if (isBlockedAction(action)) { 87 | return { modalVisible: true, message: 'Sorry you, can\'t do that!' } 88 | } 89 | return state 90 | } 91 | ``` 92 | 93 | ## Order of middleware 94 | This one is fairly self explanatory, middleware applied before your Redux Cut will receive the original action, while middleware after will get the blocked action. 95 | 96 | ```js 97 | applyMiddleware(myLoggingMiddleware, cut(criteria), ...otherMiddlewares)); 98 | ``` 99 | 100 | Here, myLoggingMiddleware will receive the action as-is, while all other middlewares will receive the blocked version. 101 | 102 | ## Is it redux-devtools friendly? 103 | You bet! Apply it before `Devtools.instrument()` and you'll see this in your monitor: 104 | 105 | 106 | 107 | Like so, if you aren't completely familiar with the concept: 108 | 109 | ```js 110 | enhancer = compose( 111 | applyMiddleware(cut(criteria), APIMiddleware), 112 | DevTools.instrument() 113 | ); 114 | ``` 115 | -------------------------------------------------------------------------------- /devtools-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonaves/redux-cut/9840b256a8a8c7cf06a1d39fed4bdfbc74d115a9/devtools-screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-cut", 3 | "version": "1.0.4", 4 | "description": "Middleware to block redux actions based on provided criteria.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "./node_modules/.bin/mocha --compilers js:babel-register", 8 | "clean": "rimraf dist", 9 | "build": "babel src --out-dir dist", 10 | "prepublish": "npm run clean && npm run test && npm run build" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/leonaves/redux-cut" 15 | }, 16 | "keywords": [ 17 | "redux", 18 | "cut", 19 | "middleware", 20 | "redux-middleware", 21 | "flux" 22 | ], 23 | "author": "Leon Aves", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "babel": "^6.5.2", 27 | "babel-preset-es2015": "^6.6.0", 28 | "babel-register": "^6.8.0", 29 | "chai": "^3.5.0", 30 | "mocha": "^2.4.5", 31 | "rimraf": "^2.5.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prefix for blocked actions (it's a no entry emoji in case you were wondering). 3 | */ 4 | const blockedActionPrefix = String.fromCharCode(55357, 57003) + ' '; 5 | 6 | /** 7 | * Accepts an action and returns true if the action is one that was dispatched by 8 | * redux cut to indicate it had blocked another action. It does this by comparing 9 | * the prefix. 10 | * 11 | * //TODO: also attach a symbol to the actions for a slightly nicer way to check. 12 | * 13 | * @param {Object} action An action to check. 14 | * 15 | * @returns {Boolean} 16 | */ 17 | const isBlockedAction = action => action.type.startsWith(blockedActionPrefix); 18 | 19 | /** 20 | * Returns the cut middleware. 21 | * 22 | * @param {Function} permitted A criteria function that is passed the current state 23 | * and the action dispatched and returns false if the action should not be permitted. 24 | * 25 | * @returns {Function} A redux middleware function that invokes every function 26 | * inside the passed object and returns false if any child criteria fail. 27 | */ 28 | const cut = permitted => store => next => action => { 29 | if (permitted(store.getState(), action) === false) { 30 | action = { 31 | type: blockedActionPrefix + action.type, 32 | payload: action 33 | }; 34 | } 35 | return next(action); 36 | }; 37 | 38 | /** 39 | * Turns an object whose values are different criteria functions, into a single 40 | * criteria function. It will call every child criteria, and return false if 41 | * any of them return false 42 | * 43 | * @param {Object} criteria An object whose values correspond to different 44 | * criteria functions that need to be combined into one. One handy way to obtain 45 | * it is to use ES6 `import * as criteria` syntax. The reducers may return anything, 46 | * but any value other than false (to block the passed action) will be ignored. 47 | * 48 | * @returns {Function} A criteria function that invokes every function inside the 49 | * passed object and returns false if any child criteria fail. 50 | */ 51 | function combineCriteria(criteria) { 52 | var criteriaKeys = Object.keys(criteria); 53 | var finalCriteria = {}; 54 | for (var i = 0; i < criteriaKeys.length; i++) { 55 | var key = criteriaKeys[i]; 56 | if (typeof criteria[key] === 'function') { 57 | finalCriteria[key] = criteria[key] 58 | } 59 | } 60 | var finalCriteriaKeys = Object.keys(finalCriteria); 61 | 62 | return function combination(state = {}, action) { 63 | 64 | var isPermitted = undefined; 65 | 66 | for (var i = 0; i < finalCriteriaKeys.length; i++) { 67 | var criteria = finalCriteria[key]; 68 | isPermitted = isPermitted !== false ? criteria(finalCriteriaKeys, action) : false; 69 | //TODO: Enable logging of which criteria failed. 70 | } 71 | return isPermitted; 72 | } 73 | } 74 | 75 | export { 76 | isBlockedAction, 77 | combineCriteria 78 | }; 79 | 80 | export default cut; 81 | -------------------------------------------------------------------------------- /test/spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import cutMiddleware, { isBlockedAction, combineCriteria } from '../src/index'; 3 | 4 | describe('cut middleware', () => { 5 | const doDispatch = () => {}; 6 | const doGetState = () => {}; 7 | 8 | it('must return a function to handle next', () => { 9 | let permitted = () => true; 10 | let nextHandler = cutMiddleware(permitted)({dispatch: doDispatch, getState: doGetState}); 11 | 12 | chai.assert.isFunction(nextHandler); 13 | chai.assert.strictEqual(nextHandler.length, 1); 14 | }); 15 | 16 | describe('handle next', () => { 17 | it('must return a function to handle action', () => { 18 | let permitted = () => true; 19 | let nextHandler = cutMiddleware(permitted)({dispatch: doDispatch, getState: doGetState}); 20 | 21 | const actionHandler = nextHandler(); 22 | 23 | chai.assert.isFunction(actionHandler); 24 | chai.assert.strictEqual(actionHandler.length, 1); 25 | }); 26 | 27 | describe('handle action', () => { 28 | 29 | it('must pass action to next as-is if permitted returns true', done => { 30 | let permitted = () => true; 31 | let nextHandler = cutMiddleware(permitted)({dispatch: doDispatch, getState: doGetState}); 32 | 33 | const actionObj = {}; 34 | 35 | const actionHandler = nextHandler(action => { 36 | chai.assert.strictEqual(action, actionObj); 37 | done(); 38 | }); 39 | 40 | actionHandler(actionObj); 41 | }); 42 | 43 | it('must return the return value of next', () => { 44 | let permitted = () => true; 45 | let nextHandler = cutMiddleware(permitted)({dispatch: doDispatch, getState: doGetState}); 46 | 47 | const expected = 'redux'; 48 | const actionHandler = nextHandler(() => expected); 49 | 50 | const outcome = actionHandler(); 51 | chai.assert.strictEqual(outcome, expected); 52 | }); 53 | 54 | it('must call next with a blocked action if permitted returns false', done => { 55 | let permitted = () => false; 56 | let nextHandler = cutMiddleware(permitted)({dispatch: doDispatch, getState: doGetState}); 57 | 58 | const actionHandler = nextHandler(action => { 59 | chai.assert.isTrue(isBlockedAction(action)); 60 | done(); 61 | }); 62 | 63 | actionHandler({}); 64 | }); 65 | 66 | it('must attach the original action as a payload to blocked actions', done => { 67 | let permitted = () => false; 68 | let nextHandler = cutMiddleware(permitted)({dispatch: doDispatch, getState: doGetState}); 69 | 70 | const actionObj = {}; 71 | 72 | const actionHandler = nextHandler(action => { 73 | chai.assert.strictEqual(action.payload, actionObj); 74 | done(); 75 | }); 76 | 77 | actionHandler(actionObj); 78 | }); 79 | }); 80 | }); 81 | }); 82 | 83 | describe('combineCriteria', () => { 84 | it('must return a new function when provided with an object of functions', () => { 85 | let criteriaFunctions = { 86 | truthy: () => true, 87 | falsey: () => false 88 | }; 89 | 90 | let permitted = combineCriteria(criteriaFunctions); 91 | chai.assert.isFunction(permitted); 92 | }); 93 | 94 | describe('combined function', () => { 95 | it('must return false if any of the child functions return false', () => { 96 | let criteriaFunctions = { 97 | truthy: () => true, 98 | falsey: () => false 99 | }; 100 | 101 | let permitted = combineCriteria(criteriaFunctions); 102 | let isPermitted = permitted(); 103 | 104 | chai.assert.strictEqual(isPermitted, false); 105 | 106 | criteriaFunctions = { 107 | falsey_one: () => false, 108 | falsey_two: () => false 109 | }; 110 | 111 | permitted = combineCriteria(criteriaFunctions); 112 | isPermitted = permitted(); 113 | 114 | chai.assert.strictEqual(isPermitted, false); 115 | }); 116 | 117 | it('must return true if all of the child functions return true', () => { 118 | let criteriaFunctions = { 119 | truthy_one: () => true, 120 | truthy_two: () => true 121 | }; 122 | 123 | let permitted = combineCriteria(criteriaFunctions); 124 | let isPermitted = permitted(); 125 | 126 | chai.assert.strictEqual(isPermitted, true); 127 | }); 128 | 129 | it('must return undefined if all of the child functions return undefined', () => { 130 | let criteriaFunctions = { 131 | undefined_one: () => undefined, 132 | undefined_two: () => undefined 133 | }; 134 | 135 | let permitted = combineCriteria(criteriaFunctions); 136 | let isPermitted = permitted(); 137 | 138 | chai.assert.strictEqual(isPermitted, undefined); 139 | }); 140 | }); 141 | }); --------------------------------------------------------------------------------