├── .nvmrc ├── .eslintignore ├── .gitignore ├── .babelrc ├── .npmignore ├── type-to-reducer.sublime-project ├── .editorconfig ├── src ├── index.d.ts └── index.js ├── .circleci └── config.yml ├── LICENSE ├── package.json ├── .eslintrc ├── README.md └── test └── index.test.js /.nvmrc: -------------------------------------------------------------------------------- 1 | v10 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib* 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | lib 4 | *.log 5 | *.sublime-workspace 6 | untracked* 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "plugins": [ 4 | "lodash", 5 | "transform-object-rest-spread" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | .babelrc 4 | .editorconfig 5 | .eslintrc 6 | .eslintignore 7 | .gitignore 8 | .nvmrc 9 | circle.yml 10 | README.md 11 | *.sublime-project 12 | -------------------------------------------------------------------------------- /type-to-reducer.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "name": "type-to-reducer", 6 | "follow_symlinks": true, 7 | "path": "." 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [{*.js, *.es6}] 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | interface reducerMapFunction { 2 | (state: S, action?: A): S; 3 | } 4 | 5 | interface reducerMapReturnFunction { 6 | (state: S | undefined, action?: A): S; 7 | } 8 | 9 | interface reducerMap { 10 | [key: string]: reducerMap | reducerMapFunction; 11 | } 12 | 13 | declare module 'type-to-reducer' { 14 | export default function typeToReducer( 15 | reducerMap: reducerMap, 16 | initialState: S, 17 | ): reducerMapReturnFunction; 18 | } 19 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/type-to-reducer 5 | docker: 6 | - image: circleci/node:10-jessie 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | key: dependency-cache-{{ checksum "package.json" }} 11 | - run: npm install 12 | - save_cache: 13 | key: dependency-cache-{{ checksum "package.json" }} 14 | paths: 15 | - node_modules 16 | - run: npm run lint 17 | - run: npm run test -- --reporter spec 18 | - run: npm run build 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Thomas 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 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // internal state 2 | let typeDelimiter = '_' 3 | 4 | // change the string separating action types when reducerMap is nested 5 | export const setTypeDelimiter = customTypeDelimiter => typeDelimiter = customTypeDelimiter 6 | 7 | // function to concatenate any nested types 8 | const _makeType = (prefix, type) => prefix.concat(type).join(typeDelimiter) 9 | 10 | // iterator function that will read the reducerMap and return a flattened object 11 | const _flattenReducerMap = (reducers, initial={}, prefix=[]) => { 12 | const reducerTypes = Object.keys(reducers || {}) 13 | return reducerTypes.reduce((acc, type) => { 14 | const reducer = reducers[type] 15 | return typeof(reducer) === 'function' 16 | ? { ...acc, [_makeType(prefix, type)]: reducer } 17 | : _flattenReducerMap(reducer, acc, [ _makeType(prefix, type) ]) 18 | }, initial) 19 | } 20 | 21 | export default function typeToReducer(reducerMap, initialState) { 22 | const flattened = _flattenReducerMap(reducerMap) 23 | 24 | return (state=initialState, action) => { 25 | const reducer = flattened[action.type] 26 | return reducer ? reducer(state, action) : state 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "type-to-reducer", 3 | "version": "1.2.0", 4 | "description": "Create reducer functions based on an object keyed by action types", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "test": "mocha test/index.test.js --ui bdd --require babel-register", 9 | "lint": "eslint . --ext .js", 10 | "build": "babel src -d lib --ignore *.test.js && cp -f src/index.d.ts lib/index.d.ts", 11 | "prepublish": "npm run build" 12 | }, 13 | "pre-push": [ 14 | "test" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/tomatau/type-to-reducer.git" 19 | }, 20 | "author": "Thomas `tomatao` Hudspith-Tatham ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/tomatau/type-to-reducer/issues" 24 | }, 25 | "homepage": "https://github.com/tomatau/type-to-reducer#readme", 26 | "devDependencies": { 27 | "babel-cli": "^6.26.0", 28 | "babel-eslint": "^10.0.2", 29 | "babel-plugin-lodash": "^3.3.4", 30 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 31 | "babel-preset-env": "^1.6.0", 32 | "babel-register": "^6.26.0", 33 | "chai": "^4.2.0", 34 | "eslint": "^6.2.2", 35 | "eslint-config-rackt": "^1.1.1", 36 | "mocha": "^7.0.1", 37 | "pre-push": "^0.1.1", 38 | "sinon": "^9.0.0", 39 | "sinon-chai": "^3.3.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "mocha": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | "parser": "babel-eslint", 9 | "parserOptions": { 10 | "sourceType": "module", 11 | }, 12 | "rules": { 13 | "array-bracket-spacing": [2, "always"], 14 | "comma-dangle": [1, "always-multiline"], 15 | "comma-spacing": ["error", { "before": false, "after": true }], 16 | "computed-property-spacing": ["error", "never"], 17 | "dot-location": [2, "property"], 18 | "eol-last": 2, 19 | "eqeqeq": [2, "allow-null"], 20 | "indent": ["error", 2, { "MemberExpression": 1, "SwitchCase": 1 }], 21 | "jsx-quotes": [2, "prefer-single"], 22 | "keyword-spacing": 2, 23 | "max-params": 2, 24 | "no-lonely-if": 2, 25 | "no-mixed-operators": 2, 26 | "no-multi-spaces": 2, 27 | "no-multiple-empty-lines": 2, 28 | "no-return-await": 2, 29 | "no-template-curly-in-string": 2, 30 | "no-undef": 2, 31 | "no-unexpected-multiline": 2, 32 | "no-unsafe-negation": 2, 33 | "no-unused-vars": 2, 34 | "object-curly-spacing": [2, "always"], 35 | "operator-linebreak": ["error", "before"], 36 | "padded-blocks": ["error", "never"], 37 | "quotes": ["error", "single", { "allowTemplateLiterals": true }], 38 | "require-yield": 0, 39 | "semi": [2, "never"], 40 | "space-before-blocks": [2, "always"], 41 | "space-before-function-paren": ["error", { 42 | "anonymous": "never", 43 | "named": "never", 44 | "asyncArrow": "always" 45 | }], 46 | "strict": 0 47 | }, 48 | "plugins": [], 49 | "globals": { 50 | "expect": true, 51 | "global": true, 52 | "window": true, 53 | "sinon": true, 54 | "_": true, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Circle CI](https://circleci.com/gh/tomatau/type-to-reducer/tree/master.svg?style=svg)](https://circleci.com/gh/tomatau/type-to-reducer/tree/master) 2 | 3 | # type-to-reducer 4 | 5 | [![Greenkeeper badge](https://badges.greenkeeper.io/tomatau/type-to-reducer.svg)](https://greenkeeper.io/) 6 | 7 | This module provides a function `typeToReducer`, which accepts an object (`reducerMap`) and returns a reducer function composing other reducer functions described by the `reducerMap`. 8 | 9 | ## Why? 10 | 11 | This is pretty much the same as the `handleActions` function you can find in https://github.com/acdlite/redux-actions. The differences being, `type-to-reducer` only exposes the function as a default, and allows nesting of the `reducerMap` object. 12 | 13 | ## Usage 14 | 15 | `npm install type-to-reducer --save` 16 | 17 | The `reducerMap` you supply to the function will have keys that correspond to dispatched action types and the values for those keys will be reducer functions. When the returned reducer function is called with state and an action object, the action's type will be matched against the previously provided `reducerMap` keys, if a match is found, the key's value (the reducer function) will be invoked with the store's state and the action. 18 | 19 | Also, you can describe an initial state with the second argument to `typeToReducer`. 20 | 21 | Oh and you can also set `reducerMap`s as the values too, these objects will be nested instances of the same shaped object you supplied to `typeToReducer`. 22 | 23 | If that sounded a bit complicated, the example below should make it clearer. NB, it helps if you're familiar with redux. 24 | 25 | ```js 26 | import typeToReducer from 'type-to-reducer' 27 | import { GET, UPDATE } from 'app/actions/foo' 28 | 29 | const initialState = { 30 | data: null, 31 | isPending: false, 32 | error: false 33 | } 34 | 35 | // supply the reducerMap object 36 | export const myReducer = typeToReducer({ 37 | // e.g. GET === 'SOME_ACTION_TYPE_STRING_FOR_GET' 38 | [GET]: (state, action) => ({ 39 | ...state, 40 | data: action.payload 41 | }), 42 | [UPDATE]: (state, action) => ({ 43 | ...state, 44 | data: action.payload 45 | }) 46 | }, initialState) 47 | ``` 48 | 49 | Then the `myReducer` would be used like so: 50 | 51 | ```js 52 | let state = { foo: 'bar' } 53 | 54 | let newState = myReducer(state, { 55 | type: GET, 56 | payload: `an action's payload.` 57 | }) 58 | 59 | // newState will deep equal { foo: 'bar', data: `an action's payload.` } 60 | ``` 61 | 62 | Group reducers by a prefix when objects are nested. 63 | 64 | ```js 65 | import typeToReducer from 'type-to-reducer' 66 | import { API_FETCH } from 'app/actions/bar' 67 | 68 | const initialState = { 69 | data: null, 70 | isPending: false, 71 | error: false 72 | } 73 | 74 | export const myReducer = typeToReducer({ 75 | [ API_FETCH ]: { 76 | PENDING: () => ({ 77 | ...initialState, 78 | isPending: true 79 | }), 80 | REJECTED: (state, action) => ({ 81 | ...initialState, 82 | error: action.payload, 83 | }), 84 | FULFILLED: (state, action) => ({ 85 | ...initialState, 86 | data: action.payload 87 | }) 88 | } 89 | }, initialState) 90 | 91 | // usage 92 | let previousState = Whatever; 93 | 94 | let newState = myReducer(previousState, { 95 | type: API_FETCH + '_' + PENDING, 96 | }) 97 | 98 | // newState shallow deeply equals { isPending: true } 99 | ``` 100 | 101 | ## Custom Type Delimiter 102 | 103 | You can add a custom type delimiter instead of the default `'_'`. 104 | 105 | This will set it for every reducer you create after the custom delimiter is set, yes a dirty stateful function. This is for convenience so you can set it for your whole project up front and not to pollute the main function abstraction for rare settings. 106 | 107 | ```js 108 | import typeToReducer, {setTypeDelimiter} from 'type-to-reducer' 109 | import { API_FETCH } from 'app/actions/bar' 110 | 111 | const initialState = { 112 | data: null, 113 | isPending: false, 114 | } 115 | 116 | setTypeDelimiter('@_@') 117 | 118 | export const myReducer = typeToReducer({ 119 | [ API_FETCH ]: { 120 | PENDING: () => ({ 121 | ...initialState, 122 | isPending: true 123 | }), 124 | } 125 | }, initialState) 126 | 127 | // Then use the delimiter in your action type 128 | myReducer(someState, { 129 | type: API_FETCH + '@_@' + PENDING, 130 | }) 131 | ``` 132 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai' 2 | import sinon from 'sinon' 3 | import typeToReducer, { setTypeDelimiter } from '../src/index' 4 | 5 | chai.use(require('sinon-chai')) 6 | 7 | describe('Handle Actions', function() { 8 | const fooAction = { type: 'FOO' } 9 | 10 | it('returns a reducer function', ()=> { 11 | const state = { test: 'state' } 12 | const reducer = typeToReducer() 13 | expect(reducer(state, fooAction)).to.eql(state) 14 | }) 15 | 16 | it('accepts an initialState for the reducer', ()=> { 17 | const initialState = { initial: 'state' } 18 | const reducer = typeToReducer({}, initialState) 19 | expect(reducer(undefined, fooAction)).to.eql(initialState) 20 | }) 21 | 22 | context('Given A Flat Reducer Config', ()=> { 23 | const initialState = { initial: 'state' } 24 | beforeEach(()=> { 25 | this.reducerConfig = { 26 | FOO: sinon.spy(() => ({ state: 'foo' })), 27 | BAR: sinon.spy(() => ({ state: 'bar' })), 28 | } 29 | this.reducer = typeToReducer(this.reducerConfig, initialState) 30 | }) 31 | 32 | it('ignores reducers when no matching type', ()=> { 33 | const differentAction = { type: 'DIFFERENT' } 34 | const newState = this.reducer(undefined, differentAction) 35 | expect(newState).to.eql(initialState) 36 | expect(this.reducerConfig.FOO).to.have.callCount(0) 37 | expect(this.reducerConfig.BAR).to.have.callCount(0) 38 | }) 39 | 40 | it('calls the reducer mapped to the action type', ()=> { 41 | const state = { given: 'state' } 42 | this.reducer(state, fooAction) 43 | expect(this.reducerConfig.FOO).to.have.callCount(1) 44 | expect(this.reducerConfig.FOO).to.have.been.calledWith( 45 | state, fooAction 46 | ) 47 | expect(this.reducerConfig.BAR).to.have.callCount(0) 48 | }) 49 | 50 | it('returns the value from the called reducer', ()=> { 51 | const actual = this.reducer({}, fooAction) 52 | const expected = this.reducerConfig.FOO() 53 | expect(actual).to.eql(expected) 54 | }) 55 | }) 56 | 57 | context('Given A Nested Reducer', ()=> { 58 | const initialState = { initial: 'state' } 59 | beforeEach(()=> { 60 | this.reducerConfig = { 61 | FOO: { 62 | 'HERP': sinon.spy(() => ({ state: 'foo_herp' })), 63 | 'DERP': { 64 | 'BAR': sinon.spy(() => ({ state: 'foo_derp_bar' })), 65 | }, 66 | }, 67 | } 68 | this.reducer = typeToReducer(this.reducerConfig, initialState) 69 | }) 70 | 71 | it('calls nested reducers with matching prefixed_type', ()=> { 72 | const state = { given: 'state' } 73 | const fooHerpAction = { type: 'FOO_HERP' } 74 | this.reducer(state, fooHerpAction) 75 | expect(this.reducerConfig.FOO.HERP).to.have.callCount(1) 76 | expect(this.reducerConfig.FOO.DERP.BAR).to.have.callCount(0) 77 | expect(this.reducerConfig.FOO.HERP).to.have.been.calledWith( 78 | state, fooHerpAction 79 | ) 80 | }) 81 | 82 | it('calls deeply nested reducers with matching prefixed_type', ()=> { 83 | const state = { given: 'state' } 84 | const fooDerpBarAction = { type: 'FOO_DERP_BAR' } 85 | this.reducer(state, fooDerpBarAction) 86 | expect(this.reducerConfig.FOO.DERP.BAR).to.have.callCount(1) 87 | expect(this.reducerConfig.FOO.HERP).to.have.callCount(0) 88 | expect(this.reducerConfig.FOO.DERP.BAR).to.have.been.calledWith( 89 | state, fooDerpBarAction 90 | ) 91 | }) 92 | }) 93 | 94 | context('Given A Custom Type Delimiter Nested Reducer', ()=> { 95 | const initialState = { initial: 'state' } 96 | beforeEach(()=> { 97 | this.reducerConfig = { 98 | FOO: { 99 | 'HERP': sinon.spy(() => ({ state: 'foo_herp' })), 100 | 'DERP': { 101 | 'BAR': sinon.spy(() => ({ state: 'foo_derp_bar' })), 102 | }, 103 | }, 104 | } 105 | setTypeDelimiter('|') 106 | this.reducer = typeToReducer(this.reducerConfig, initialState) 107 | }) 108 | 109 | it('calls deeply nested reducers with matching prefixed_type', ()=> { 110 | const state = { given: 'state' } 111 | const fooDerpBarAction = { type: 'FOO|DERP|BAR' } 112 | this.reducer(state, fooDerpBarAction) 113 | expect(this.reducerConfig.FOO.DERP.BAR).to.have.callCount(1) 114 | expect(this.reducerConfig.FOO.HERP).to.have.callCount(0) 115 | expect(this.reducerConfig.FOO.DERP.BAR).to.have.been.calledWith( 116 | state, fooDerpBarAction 117 | ) 118 | }) 119 | }) 120 | }) 121 | --------------------------------------------------------------------------------