├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── package.json ├── src └── index.js └── test └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-1"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | coverage 4 | *.log 5 | lib -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .babelrc 3 | .eslintrc 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | script: 5 | - npm run eslint 6 | - npm run test 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Anton Petrov 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux Entities 2 | 3 | [![build status](https://img.shields.io/travis/itsmepetrov/redux-entities/master.svg?style=flat-square)](https://travis-ci.org/itsmepetrov/redux-entities) 4 | [![npm version](https://img.shields.io/npm/v/redux-entities.svg?style=flat-square)](https://www.npmjs.com/package/redux-entities) 5 | 6 | Higher-order reducer for store entities received from [normalizr](https://github.com/paularmstrong/normalizr) and makes it easy to handle them. 7 | 8 | ### Installation 9 | 10 | ``` 11 | npm install --save redux-entities 12 | ``` 13 | 14 | ## Usage 15 | 16 | ### Use with `entitiesReducer` 17 | 18 | ```js 19 | import { combineReducers } from 'redux'; 20 | import { entitiesReducer } from 'redux-entities'; 21 | import { merge, omit } from 'lodash'; 22 | 23 | function contacts(state = {}, action) { 24 | const { type, payload } = action; 25 | 26 | switch (type) { 27 | 28 | case UPDATE_CONTACT: 29 | case REMOVE_CONTACT: 30 | return merge({}, state, { [payload.id]: { 31 | ...state[payload.id], 32 | isPending: true 33 | }}); 34 | 35 | case UPDATE_CONTACT_SUCCESS: 36 | return merge({}, state, { [payload.id]: { 37 | ...state[payload.id], 38 | isPending: false 39 | }}); 40 | 41 | case REMOVE_CONTACT_SUCCESS: 42 | return omit(state, meta.id); 43 | 44 | default: 45 | return state; 46 | } 47 | } 48 | 49 | export default combineReducers({ 50 | contacts: entitiesReducer(contacts, 'contacts') 51 | }); 52 | 53 | ``` 54 | 55 | ### Use with `combineEntitiesReducers` 56 | 57 | ```js 58 | import { combineEntitiesReducers } from 'redux-entities'; 59 | import { contacts, groups, images, notes } from './entities'; 60 | 61 | export default combineEntitiesReducers({ 62 | contacts, 63 | groups, 64 | images, 65 | notes 66 | }); 67 | 68 | ``` 69 | 70 | ## Immutable 71 | 72 | If you want to use `Immutable` with `Redux` please check out this version of the library: [redux-entities-immutable](https://github.com/beautyfree/redux-entities-immutable) 73 | 74 | 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-entities", 3 | "version": "2.0.0", 4 | "description": "Higher-order reducer for store entities received from normalizr and makes it easy to handle them.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "mocha --compilers js:babel-core/register --recursive", 8 | "eslint": "eslint ./src/*.js", 9 | "build": "babel src --out-dir lib", 10 | "clean": "rimraf lib", 11 | "prepublish": "npm run clean && npm run test && npm run build" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/itsmepetrov/redux-entities.git" 16 | }, 17 | "keywords": [ 18 | "redux", 19 | "reducer", 20 | "entities", 21 | "normalizr", 22 | "flux" 23 | ], 24 | "author": "Anton Petrov (http://github.com/itsmepetrov)", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/itsmepetrov/redux-entities/issues" 28 | }, 29 | "homepage": "https://github.com/itsmepetrov/redux-entities#readme", 30 | "devDependencies": { 31 | "babel-cli": "^6.14.0", 32 | "babel-core": "^6.14.0", 33 | "babel-eslint": "^6.1.2", 34 | "babel-preset-es2015": "^6.14.0", 35 | "babel-preset-stage-1": "^6.13.0", 36 | "babel-register": "^6.14.0", 37 | "chai": "^3.5.0", 38 | "eslint": "^3.4.0", 39 | "mocha": "^3.0.2", 40 | "rimraf": "^2.4.3" 41 | }, 42 | "dependencies": { 43 | "lodash": "^4.11.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash/isFunction' 2 | import mapValues from 'lodash/mapValues' 3 | import merge from 'lodash/merge' 4 | import get from 'lodash/get' 5 | 6 | function selectEntities(action, name) { 7 | const entities = get(action, `payload.entities.${name}`) 8 | if (entities) { 9 | return entities; 10 | } 11 | } 12 | 13 | export function actionless(state = {}) { 14 | return state 15 | } 16 | 17 | export function entitiesReducer(reducer, entitiesName) { 18 | return (state, action) => { 19 | let newState = state; 20 | const entities = isFunction(entitiesName) ? 21 | entitiesName(action) : selectEntities(action, entitiesName); 22 | 23 | if (entities) { 24 | newState = merge({}, newState, entities); 25 | } 26 | 27 | return reducer(newState, action); 28 | }; 29 | } 30 | 31 | export function combineEntitiesReducers(reducers) { 32 | const entitiesReducers = mapValues(reducers, entitiesReducer); 33 | return (state = {}, action) => mapValues( 34 | entitiesReducers, 35 | (reducer, key) => reducer(state[key], action) 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { expect } from 'chai'; 3 | import { merge } from 'lodash'; 4 | import { entitiesReducer, combineEntitiesReducers, actionless } from '../src'; 5 | 6 | describe('redux-entities', () => { 7 | const normalizedObject = { 8 | entities: { 9 | contacts: { 10 | 1: { 11 | id: 1, 12 | name: 'Anton' 13 | }, 14 | 2: { 15 | id: 2, 16 | name: 'Sergey' 17 | } 18 | }, 19 | notes: { 20 | 10: { 21 | id: 10, 22 | title: 'Run tests' 23 | }, 24 | 20: { 25 | id: 20, 26 | title: 'Run lint' 27 | } 28 | } 29 | } 30 | } 31 | 32 | const normalizedObjectWithNewFields = { 33 | entities: { 34 | contacts: { 35 | 1: { 36 | id: 1, 37 | phone: '54321' 38 | }, 39 | 2: { 40 | id: 2, 41 | phone: '12345' 42 | } 43 | }, 44 | } 45 | } 46 | 47 | const fillEntitiesAction = { 48 | type: 'FILL_ENTITIES', 49 | payload: normalizedObject 50 | } 51 | 52 | const fillEntitiesNestedAction = { 53 | type: 'FILL_ENTITIES', 54 | payload: { 55 | nested: normalizedObject 56 | } 57 | } 58 | 59 | const fillEntitiesWithNewFieldsAction = { 60 | type: 'FILL_ENTITIES', 61 | payload: { 62 | nested: normalizedObjectWithNewFields 63 | } 64 | } 65 | 66 | const updateContactAction = { 67 | type: 'UPDATE_CONTACT', 68 | payload: { 69 | id: 1, 70 | name: 'Andrey' 71 | } 72 | } 73 | 74 | const contactsReducer = (state = {}) => state; 75 | const notesReducer = (state = {}) => state; 76 | const contactsWithUpdateReducer = (state = {}, action) => { 77 | const { type, payload } = action; 78 | if (type === 'UPDATE_CONTACT') { 79 | const { id, ...rest } = payload 80 | return { 81 | ...state, 82 | [id]: { 83 | ...state[id], 84 | ...rest 85 | } 86 | } 87 | } 88 | return state 89 | } 90 | 91 | describe('entitiesReducer', () => { 92 | it('should return named entities object', () => { 93 | const reducer = entitiesReducer( 94 | contactsReducer, 95 | 'contacts' 96 | ) 97 | 98 | const state = reducer({}, fillEntitiesAction) 99 | 100 | expect(state).to.deep.equal(normalizedObject.entities.contacts) 101 | }) 102 | 103 | it('can extract entities by custom path', () => { 104 | const reducer = entitiesReducer( 105 | contactsReducer, 106 | (action) => action.payload.nested.entities.contacts 107 | ) 108 | 109 | const state = reducer({}, fillEntitiesNestedAction) 110 | 111 | expect(state).to.deep.equal(normalizedObject.entities.contacts) 112 | }) 113 | 114 | it('can merge entities fields', () => { 115 | const reducer = entitiesReducer( 116 | contactsReducer, 117 | (action) => action.payload.nested.entities.contacts 118 | ) 119 | 120 | const state = reducer({}, fillEntitiesNestedAction) 121 | const updatedState = reducer(state, fillEntitiesWithNewFieldsAction) 122 | 123 | expect(updatedState).to.deep.equal( 124 | merge({}, normalizedObject, normalizedObjectWithNewFields).entities.contacts 125 | ) 126 | }) 127 | 128 | it('can update specific entitie', () => { 129 | const reducer = entitiesReducer( 130 | contactsWithUpdateReducer, 131 | 'contacts' 132 | ) 133 | 134 | const state = reducer({}, fillEntitiesAction) 135 | const updatedState = reducer(state, updateContactAction) 136 | 137 | expect(updatedState[1]).to.deep.equal({ id: 1, name: 'Andrey' }) 138 | }) 139 | }) 140 | 141 | describe('combineEntitiesReducers', () => { 142 | it('should return entities object', () => { 143 | const reducer = combineEntitiesReducers({ 144 | contacts: contactsReducer, 145 | notes: notesReducer, 146 | }) 147 | 148 | const state = reducer({}, fillEntitiesAction) 149 | 150 | expect(state).to.deep.equal(normalizedObject.entities) 151 | }) 152 | 153 | it('can update specific entitie', () => { 154 | const reducer = combineEntitiesReducers({ 155 | contacts: contactsWithUpdateReducer, 156 | notes: notesReducer, 157 | }) 158 | 159 | const state = reducer({}, fillEntitiesAction) 160 | const updatedState = reducer(state, updateContactAction) 161 | 162 | expect(updatedState.contacts[1]).to.deep.equal({ id: 1, name: 'Andrey' }) 163 | }) 164 | }) 165 | 166 | describe('actionless', () => { 167 | it('should return empty object', () => { 168 | const state = actionless() 169 | 170 | expect(state).to.deep.equal({}) 171 | }) 172 | 173 | it('should return passed object', () => { 174 | const state = actionless(normalizedObject.entities.contacts) 175 | 176 | expect(state).to.deep.equal(normalizedObject.entities.contacts) 177 | }) 178 | }) 179 | }) 180 | --------------------------------------------------------------------------------