├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs └── API.md ├── entman-logo.png ├── karma.conf.js ├── package.json ├── src ├── helpers.js ├── index.js ├── middleware.js ├── reducer │ ├── create-reactions.js │ ├── entity.js │ └── index.js ├── schema.js ├── selectors.js └── utils │ └── index.js ├── test ├── index.js ├── integration │ ├── actions.js │ ├── full.js │ ├── index.js │ ├── mock-api.js │ ├── schemas.js │ └── store.js └── unit │ ├── actions.js │ ├── helpers.js │ ├── index.js │ ├── reducer.js │ ├── schema.js │ ├── selectors.js │ └── utils │ └── index.js ├── vendor └── .gitkeep ├── webpack.base.config.js ├── webpack.config.js ├── webpack.test.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ ["es2015"], "stage-0" ], 3 | "env": { 4 | "test": { 5 | "plugins": [ "istanbul" ] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "es6": true, 6 | "browser": true, 7 | "node": true, 8 | "mocha": true 9 | }, 10 | "rules": { 11 | "strict": 0, 12 | "no-console": 0, 13 | "comma-dangle": 0, 14 | "no-constant-condition": 0, 15 | "no-unused-vars": 1, 16 | "jsx-quotes": 1, 17 | "quotes": [2, "single"], 18 | "indent": [2, 2, { "SwitchCase": 1 }], 19 | "semi": [2, "always"] 20 | }, 21 | "ecmaFeatures": { 22 | "experimentalObjectRestSpread": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | lib 4 | dist 5 | coverage 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | cache: yarn 5 | cache: 6 | yarn: true 7 | directories: 8 | - node_modules 9 | after_success: 10 | - "cat coverage/lcov/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016 Drawbotics 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Entman 5 | 6 | A library to help you manage your entities in a [redux](https://github.com/reactjs/redux) 7 | store when using [normalizr](https://github.com/paularmstrong/normalizr). **Entman** takes care of 8 | retrieving them from the store and creating, deleting 9 | and updating the entities while keeping the relations between them in sync. 10 | 11 | The idea is that everything that has a model in the *backend* should be an 12 | entity in the *frontend*. The management of entities is usually something very 13 | straightforward but tedious, so you leave this work to **entman** and 14 | you can focus on the rest. 15 | 16 | [![npm version](https://img.shields.io/npm/v/entman.svg?style=flat-square)](https://www.npmjs.com/package/entman) 17 | [![build status](https://img.shields.io/travis/Drawbotics/entman/master.svg?style=flat-square)](https://travis-ci.org/Drawbotics/entman) 18 | [![coveralls](https://img.shields.io/coveralls/Drawbotics/entman.svg?style=flat-square)](https://coveralls.io/github/Drawbotics/entman) 19 | 20 | ## Install 21 | 22 | Install it as a node module as usual with [npm](https://www.npmjs.org/) along its peer dependencies: 23 | 24 | ```bash 25 | $ npm install -S entman redux normalizr 26 | ``` 27 | 28 | Or using [yarn](https://yarnpkg.com/): 29 | 30 | ```bash 31 | $ yarn add entman redux normalizr 32 | ``` 33 | 34 | 35 | ## Example 36 | 37 | A quick example to see **entman** in action: 38 | 39 | ### schemas.js 40 | 41 | We use schemas to define relationships between our entities. We can also define 42 | methods that will be available in the entity and serve like some sort of computed property. 43 | 44 | ```javascript 45 | import { defineSchema, hasMany, generateSchemas } from 'entman'; 46 | 47 | const Group = defineSchema('Group', { 48 | attributes: { 49 | users: hasMany('User'), // Use the name of another model to define relationships 50 | 51 | getNumberOfUsers() { // Define methods that interact with the entity instance 52 | return this.users.length; 53 | } 54 | } 55 | }); 56 | 57 | const User = defineSchema('User', { 58 | attributes: { 59 | group: 'Group', 60 | } 61 | }); 62 | 63 | // Generate and export the schemas. Schemas will be exported as an object 64 | // with the name of every schema as the keys and the actual schemas as values. 65 | export default generateSchemas([ 66 | Group, 67 | User, 68 | ]) 69 | ``` 70 | 71 | 72 | ### reducer.js 73 | 74 | Connect the entities reducer to the state. 75 | 76 | ```javascript 77 | import { combineReducers } from 'redux'; 78 | import { reducer as entities } from 'entman'; 79 | import schemas from './schemas'; 80 | 81 | export default combineReducers({ 82 | // Other reducers, 83 | entities: entities(schemas, { 84 | // An initial state can also be specified 85 | Group: { 86 | 1: { id: 1 }, 87 | }, 88 | }), 89 | }) 90 | ``` 91 | 92 | 93 | ### store.js 94 | 95 | Connect the entman middleware to the store. 96 | 97 | ```javascript 98 | import { createStore, applyMiddleware } from 'redux'; 99 | import { middleware as entman } from 'entman'; 100 | import reducer from './reducer'; 101 | 102 | export default createStore( 103 | store, 104 | applyMiddleware(entman({ enableBatching: true })), 105 | ); 106 | ``` 107 | 108 | 109 | ### selectors.js 110 | 111 | Create selectors that will retrieve the entities from the store. Selectors also 112 | take care of populating relationships and adding the *getter* methods defined in the 113 | schema. It's recommended to wrap **entman** selectors intead of using them directly 114 | so they're abstracted from the rest of the system. 115 | 116 | ```javascript 117 | import { getEntity } from 'entman'; 118 | import schemas from './schemas'; 119 | 120 | export function getGroup(state, id) { 121 | return getEntity(state, schemas.Group, id); 122 | } 123 | ``` 124 | 125 | 126 | ### actions.js 127 | 128 | Create some actions using the helpers from **entman**. The helpers will take an action and 129 | wrap it with entman functionality. This way, you can still react in your reducers to your 130 | own actions and the entity management is just a side effect that entman will take care of. 131 | 132 | ```javascript 133 | import { 134 | createEntities, 135 | } from 'entman'; 136 | import schemas from './schemas'; 137 | 138 | export const CREATE_USER = 'CREATE_USER'; 139 | 140 | export function createUser(user) { 141 | return createEntities(schemas.User, 'payload.user', { 142 | type: CREATE_USER, // CREATE_USER action will be dispatched alongside entman actions 143 | payload: { user }, 144 | }); 145 | } 146 | ``` 147 | 148 | 149 | ### Group.jsx 150 | 151 | Finally, use your actions and selectors like you would normally do. 152 | 153 | ```jsx 154 | import React from 'react'; 155 | import { connect } from 'react-redux'; 156 | import { getGroup } from './selectors'; 157 | import { loadGroup, createUser } from './actions'; 158 | 159 | class Group extends React.Component { 160 | constructor(props) { 161 | super(props); 162 | this._handleInput = this._handleInput.bind(this); 163 | this._handleAddUser = this._handleAddUser.bind(this); 164 | } 165 | 166 | componentDidMount() { 167 | const { loadGroup, params } = this.props; 168 | loadGroup(params.groupId); 169 | } 170 | 171 | render() { 172 | const { group } = this.props; 173 | return ( 174 |
175 |

{group.name}

176 |

{group.getNumberOfUsers()} members

177 | 182 | {this.state.showForm && 183 | this._renderUserForm() 184 | } 185 | { ! this.state.showForm && 186 | 189 | } 190 |
191 | ); 192 | } 193 | 194 | _renderUserForm() { 195 | return ( 196 |
197 | 198 | 201 | 202 |
203 | ); 204 | } 205 | 206 | _handleInput(e) { 207 | this.setState({ name: e.target.value }); 208 | } 209 | 210 | _handleAddUser(e) { 211 | const { group, createUser } = this.props; 212 | const { name } = this.state; 213 | const user = { name, group: group.id }; 214 | createUser(user); 215 | } 216 | } 217 | 218 | const mapStateToProps = (state, ownProps) => ({ 219 | group: getGroup(state, ownProps.params.groupId), 220 | }); 221 | 222 | const mapDispatchToProps = { 223 | loadGroup, 224 | createUser, 225 | }; 226 | 227 | export default connect(mapStateToProps, mapDispatchToProps)(Group); 228 | ``` 229 | 230 | 231 | ## API 232 | 233 | See the [API Reference](docs/API.md) 234 | 235 | 236 | ## LICENSE 237 | 238 | MIT. See [LICENSE](LICENSE) for details. 239 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # API Reference (v0.3.3) 2 | 3 | - [Reducer](#reducer) 4 | - [`reducer(schemas, initialState)`](#reducerschemas) 5 | - [Middleware](#middleware) 6 | - [`middleware(config)`](#middlewareconfig) 7 | - [Schema](#schema) 8 | - [`defineSchema(name, config)`](#defineschemaname-config) 9 | - [`hasMany(name)`](#hasmanyname) 10 | - [`generateSchemas(schemas)`](#generateschemasschemas) 11 | - [Selectors](#selectors) 12 | - [`getEntities(state, schema)`](#getentitiesstate-schema) 13 | - [`getEntitiesBy(state, schema, by)`](#getentitiesbystate-schema-by) 14 | - [`getEntity(state, schema, id)`](#getentitystate-schema-id) 15 | - [Helpers](#helpers) 16 | - [`createEntities(schema, dataPath, action)`](#createentitiesschema-datapath-action) 17 | - [`updateEntities(schema, ids, dataPath, action)`](#updateentitiesschema-ids-datapath-action) 18 | - [`updateEntityId(schema, oldId, newId, action)`](#updateentityidschema-oldid-newid-action) 19 | - [`deleteEntities(schema, ids, action)`](#deleteentitiesschema-ids-action) 20 | 21 | ## Reducer 22 | 23 | #### `reducer(schemas, initialState)` 24 | 25 | > Creates the reducer that will manage the *entities* slice of the store. It should be mounted at `entities`. 26 | 27 | - **Parameters** 28 | - `schemas` *Object*: Object containing entity schemas. Usually the output of [`generateSchemas(schemas)`](). 29 | - `initialState` *Object*: Object containing an initial state for each entity. Each key of this object has to be the name of an entity and 30 | the value has to be the initial state for that entity. 31 | - **Returns** 32 | - *Function*: The reducer that will manage the *entities* slice of the store. 33 | 34 | ```javascript 35 | import { reducer as entities } from 'entman'; 36 | import { combineReducers } from 'redux'; 37 | import { schemas } from './schemas'; 38 | 39 | const topReducer = combineReducers({ 40 | // ...other reducers of the application 41 | entities: entities(schemas, { 42 | Group: { 1: { id: 1 } }, 43 | }), 44 | }); 45 | ``` 46 | 47 | ## Middleware 48 | 49 | #### `middleware(config)` 50 | 51 | > Creates the entman middleware needed to process the actions generated by the [helpers](). 52 | 53 | - **Parameters** 54 | - `config` *Object*: An object containing configuration options to pass to the middleware. Currently, the supported options are: 55 | - `enableBatching`: Enable or disable batching of multiple entman actions into a single action. **Defaults to true**. 56 | - **Returns** 57 | - *Function*: A Redux middleware. 58 | 59 | ```javascript 60 | import { createStore, applyMiddleware } from 'redux'; 61 | import { middleware as entman } from 'entman'; 62 | import reducer from './reducer'; 63 | 64 | export default createStore({ 65 | reducer, 66 | applyMiddleware(entman({ enableBatching: true }), 67 | }); 68 | ``` 69 | 70 | ## Schema 71 | 72 | #### `defineSchema(name, config)` 73 | 74 | > Creates an schema definition with the given named to be used in `generateSchemas`. 75 | 76 | - **Parameters** 77 | - `name` *String*: A string indicating the name of the entity this schema defines. 78 | - `config` *Object*: An object with the information to define the schema. The attributes are: 79 | - `attributes`: An object containing information about the relations of this entity to other entities and computed properties to be added to the entity when it's retrieved from the store. 80 | - **Returns** 81 | - *Object*: Schema definition of the entity. 82 | 83 | ```javascript 84 | import { defineSchema } from 'entman'; 85 | 86 | const userDefinition = defineSchema('User', { 87 |  group: 'Group', // User belongs to Group 88 | 89 | // Define computed properties as functions 90 |  getGroupName() { 91 |    return this.group.name; 92 | }, 93 | ); 94 | ``` 95 | 96 | ##### Note 97 | 98 | When retrieving users from the store, they will contain defined computed properties. Inside computed properties we can compute data based on the entity and its relations. We can know for sure that in every place we're going to use that entity, the computed properties will be there, saving a lot imports around the application with functions to compute the same data. 99 | 100 | #### `hasMany(name)` 101 | 102 | > Defines an array like relationship with the entity identified by `name`. 103 | 104 | - **Parameters** 105 | - `name` *String*: A string indicating the name of the related entity. 106 | - **Returns** 107 | - *Object*: A relationship definition. 108 | 109 | ```javascript 110 | import { defineSchema, hasMany } from 'entman'; 111 | 112 | const groupDefinition = defineSchema('Group', { 113 |  users: hasMany('User') // Group has many users 114 | }); 115 | ``` 116 | 117 | #### `generateSchemas(schemas)` 118 | 119 | > Generates entities schemas from the definitions. The generated result is ready to be passed to the entities reducer. 120 | 121 | - **Parameters** 122 | - `schemas` *Array*: An array containing schemas definitions. 123 | - **Returns** 124 | - *Object*: An object with the schemas of the entities. 125 | 126 | ```javascript 127 | import { defineSchema, generateSchemas } from 'entman'; 128 | 129 | const userDefinition = defineSchema('User'); 130 | 131 | export default generateSchemas([ userDefinition ]); 132 | ``` 133 | 134 | ## Selectors 135 | 136 | #### `getEntities(state, schema)` 137 | 138 | > Get all the entities defined by `schema` from the state. It takes care of populate all the entities relationships and adding the computed properties defined in the schema. 139 | 140 | - **Parameters** 141 | - `state` *Object*: The global state of the application or an slice that contains the key `entities` on it. 142 | - `schema` *Object*: The schema of the entities to retrieve. 143 | - **Returns** 144 | - *Object*: An array with all the entities of the specified schema. 145 | 146 | ```javascript 147 | import { getEntities } from 'entman'; 148 | import schemas from './schemas'; 149 | 150 | function getGroups(state) { 151 | return getEntities(state, schemas.Group); 152 | } 153 | 154 | // ----- 155 | 156 | const groups = getGroups(state); 157 | ``` 158 | 159 | #### `getEntitiesBy(state, schema, by)` 160 | 161 | > Get all the entities defined by `schema` from the state that match certain conditions. The conditions are specified by the `by` parameter which is an object that takes attributes of the entities as keys and the values these have to have as values to match. It takes care of populate all the entities relationships and adding the computed properties defined in the schema. 162 | 163 | - **Parameters** 164 | - `state` *Object*: The global state of the application or an slice that contains the key `entities` on it. 165 | - `schema` *Object*: The schema of the entities to retrieve. 166 | - `by` *Object*: An object specifying attributes of the entities and which value do they have to have. All entities matching those values are retrieved. 167 | - **Returns** 168 | - *Object*: An array with all the entities of the specified schema that match the conditions specified. 169 | 170 | ```javascript 171 | import { getEntitiesBy } from 'entman'; 172 | import schemas from './schemas'; 173 | 174 | function getGroupsBy(state, by) { 175 | return getEntities(state, schemas.Group, by); 176 | } 177 | 178 | // ----- 179 | 180 | const groups = getGroupsBy(state, { name: 'Test' }); 181 | ``` 182 | 183 | #### `getEntity(state, schema, id)` 184 | 185 | > Get a single entity defined by `schema` with the specified `id` from the state. It takes care of populate all the entity relationships and adding the computed properties defined in the schema. 186 | 187 | - **Parameters** 188 | - `state` *Object*: The global state of the application or an slice that contains the key `entities` on it. 189 | - `schema` *Object*: The schema of the entities to retrieve. 190 | - `id` *String|Number*: The id of the entity to retrieve. 191 | - **Returns** 192 | - *Object*: The entity with the specified id. 193 | 194 | ```javascript 195 | import { getEntity } from 'entman'; 196 | import schemas from './schemas'; 197 | 198 | function getGroup(state, id) { 199 | return getEntity(state, schemas.Group, id); 200 | } 201 | 202 | // ----- 203 | 204 | const group = getGroup(state, 1); 205 | ``` 206 | 207 | ## Helpers 208 | 209 | #### `createEntities(schema, dataPath, action)` 210 | 211 | > Wraps an action adding the necessary info for entman to understand it has to add entities to the state. 212 | 213 | - **Parameters**: 214 | - `schema` *Object*: The schema of the entities to be created. 215 | - `dataPath` *String*: The path in dot notation of where the data of the entities is located in the wrapped action. 216 | - `action` *Object: The action to wrap. It has to be a valid Redux action. 217 | - **Returns**: 218 | - *Object*: The wrapped action. 219 | 220 | ```javascript 221 | import { createEntities } from 'entman'; 222 | import schemas from 'schemas'; 223 | 224 | export const CREATE_GROUPS = 'CREATE_GROUPS'; 225 | 226 | export function createGroups(data) { 227 | return createEntities(schemas.Group, 'payload.data', { 228 | type: CREATE_GROUPS, 229 | payload: { data }, 230 | }); 231 | } 232 | ``` 233 | 234 | #### `updateEntities(schema, ids, dataPath, action)` 235 | 236 | > Wraps an action adding the necessary info for entman to understand it has to update entities in the state. 237 | 238 | - **Parameters** 239 | - `schema` *Object*: The schema of the entities to be updated. 240 | - `ids` *Array|Number|String*: The id or ids of the entities to be updated. 241 | - `dataPath` *String*: The path in dot notation of where the data of the entities is located in the wrapped action. 242 | - `action` *Object*: The action to wrap. It has to be a valid Redux action. 243 | - **Returns**: 244 | - *Object*: The wrapped action. 245 | 246 | ```javascript 247 | import { updateEntities } from 'entman'; 248 | import schemas from 'schemas'; 249 | 250 | export const UPDATE_GROUP = 'UPDATE_GROUP'; 251 | 252 | export function updateGroup(1, data) { 253 | return updateEntities(schemas.Group, 1, 'payload.data', { 254 | type: UPDATE_GROUP, 255 | payload: { data }, 256 | }); 257 | } 258 | ``` 259 | 260 | #### `updateEntityId(schema, oldId, newId, action)` 261 | 262 | > Wraps an action adding the necessary info for entman to understand it has to update the id of an entity in the state. 263 | 264 | - **Parameters** 265 | - `schema` *Object*: The schema of the entity to be updated. 266 | - `oldId` *Number|String*: The actual id of the entity. 267 | - `newId` *Number|String*: The new id of the entity. 268 | - `action` *Object*: The action to wrap. It has to be a valid Redux action. 269 | - **Returns** 270 | - *Object*: The wrapped action. 271 | 272 | ```javascript 273 | import { updateEntityId } from 'entman'; 274 | import schemas from 'schemas'; 275 | 276 | export const SAVE_GROUP_SUCCESS = 'SAVE_GROUP_SUCCESS'; 277 | 278 | export function saveGroupSuccess(oldId, newId) { 279 | return updateEntityId(schemas.Group, oldId, newId, { 280 | type: SAVE_GROUP_SUCCESS, 281 | }); 282 | } 283 | ``` 284 | 285 | #### `deleteEntities(schema, ids, action)` 286 | 287 | > Wraps an action adding the necessary info for entman to understand it has to delete entities from the state. 288 | 289 | - **Parameters** 290 | - `schema` *Object*: The schema of the entity to be deleted. 291 | - `ids` *Array|Number|String*: The id or ids of the entities to be deleted. 292 | - `action` *Object*: The action to wrap. It has to be a valid Redux action. 293 | - **Returns** 294 | - *Object*: The wrapped action. 295 | 296 | ```javascript 297 | import { deleteEntities } from 'entman'; 298 | import schemas from 'schemas'; 299 | 300 | export const DELETE_GROUP = 'DELETE_GROUP'; 301 | 302 | export function deleteGroup(id) { 303 | return deleteEntities(schemas.Group, id, { 304 | type: DELETE_GROUP, 305 | }); 306 | } 307 | ``` 308 | -------------------------------------------------------------------------------- /entman-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Drawbotics/entman/19e0f39e3c6dc9cf17e3132f6a161e4cb2d6b0e5/entman-logo.png -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const webpackTestConfig = require('./webpack.test.config.js'); 2 | 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | // base path that will be used to resolve all patterns (eg. files, exclude) 7 | basePath: './', 8 | 9 | // frameworks to use 10 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 11 | frameworks: ['mocha', 'chai'], 12 | 13 | // list of files / patterns to load in the browser 14 | files: [ 15 | 'test/index.js', 16 | ], 17 | 18 | // list of files to exclude 19 | exclude: [ 20 | ], 21 | 22 | // preprocess matching files before serving them to the browser 23 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 24 | preprocessors: { 25 | './test/index.js': ['webpack', 'sourcemap'], 26 | }, 27 | 28 | // test results reporter to use 29 | // possible values: 'dots', 'progress' 30 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 31 | reporters: [ 32 | 'mocha', 33 | 'coverage', 34 | ], 35 | 36 | // web server port 37 | port: 9876, 38 | 39 | // enable / disable colors in the output (reporters and logs) 40 | colors: true, 41 | 42 | // level of logging 43 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 44 | logLevel: config.LOG_INFO, 45 | 46 | // enable / disable watching file and executing tests whenever any file changes 47 | autoWatch: true, 48 | 49 | // start these browsers 50 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 51 | browsers: [ 52 | //'Chrome', 53 | //'PhantomJS', 54 | ], 55 | 56 | client: { 57 | clearContext: false, 58 | }, 59 | 60 | // Continuous Integration mode 61 | // if true, Karma captures browsers, runs the tests and exits 62 | singleRun: false, 63 | 64 | // Concurrency level 65 | // how many browser should be started simultaneous 66 | concurrency: Infinity, 67 | 68 | // Webpack configuration 69 | webpack: webpackTestConfig, 70 | webpackMiddleware: { 71 | noInfo: false, 72 | stats: { colors: true }, 73 | }, 74 | 75 | // Show diff when failing equality tests 76 | mochaReporter: { 77 | showDiff: true 78 | }, 79 | 80 | // Configure coverage reporter 81 | coverageReporter: { 82 | dir: 'coverage/', 83 | reporters: [ 84 | { 85 | type: 'lcovonly', 86 | subdir: 'lcov', 87 | }, 88 | { 89 | type: 'html', 90 | subdir: 'html', 91 | }, 92 | ], 93 | } 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "entman", 3 | "version": "1.0.0", 4 | "description": "A manager of normalizr entities for Redux", 5 | "author": "Lorenzo Ruiz ", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "clean": "rimraf lib/ dist/", 9 | "karma": "NODE_ENV=test rimraf coverage/ && karma start --single-run --browsers PhantomJS", 10 | "karma-watch": "NODE_ENV=test karma start", 11 | "build:umd": "webpack --progress", 12 | "build:commonjs": "babel src --out-dir lib/", 13 | "build": "npm run clean && npm run build:umd && npm run build:commonjs", 14 | "test": "NODE_ENV=test npm run build && npm run karma", 15 | "start": "npm run karma-watch", 16 | "prepublish": "npm run build && npm run karma" 17 | }, 18 | "keywords": [], 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/Drawbotics/entman.git" 23 | }, 24 | "peerDependencies": { 25 | "redux": "^3.5.2" 26 | }, 27 | "devDependencies": { 28 | "babel-cli": "^6.23.0", 29 | "babel-core": "^6.23.1", 30 | "babel-eslint": "^6.1.2", 31 | "babel-loader": "^6.2.4", 32 | "babel-plugin-istanbul": "^4.0.0", 33 | "babel-plugin-transform-decorators": "^6.13.0", 34 | "babel-preset-es2015": "^6.22.0", 35 | "babel-preset-stage-0": "^6.22.0", 36 | "chai": "^3.5.0", 37 | "coveralls": "^2.11.16", 38 | "deep-freeze": "0.0.1", 39 | "eslint": "^3.3.1", 40 | "eslint-loader": "^1.5.0", 41 | "istanbul-instrumenter-loader": "^2.0.0", 42 | "karma": "^1.5.0", 43 | "karma-chai": "^0.1.0", 44 | "karma-chrome-launcher": "^1.0.1", 45 | "karma-coverage": "^1.1.1", 46 | "karma-mocha": "^1.1.1", 47 | "karma-mocha-reporter": "^2.1.0", 48 | "karma-phantomjs-launcher": "^1.0.1", 49 | "karma-sourcemap-loader": "^0.3.7", 50 | "karma-webpack": "^2.0.2", 51 | "mocha": "^3.0.2", 52 | "phantomjs-prebuilt": "2.1.14", 53 | "redux": "^3.5.2", 54 | "rimraf": "^2.5.4", 55 | "webpack": "^2.2.1", 56 | "webpack-bundle-analyzer": "^2.3.0" 57 | }, 58 | "dependencies": { 59 | "babel-polyfill": "^6.23.0", 60 | "entman-denormalizr": "0.7.2", 61 | "lodash": "^4.15.0", 62 | "normalizr": "^3.2.1", 63 | "redux-batched-actions": "^0.1.5", 64 | "uuid": "^3.0.1" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import isEmpty from 'lodash/isEmpty'; 3 | 4 | 5 | export function createEntities(schema, dataPath, action) { 6 | if ( ! schema || ! schema.key) { 7 | throw new Error(`[INVALID SCHEMA]: Entity schema expected instead of ${schema}`); 8 | } 9 | if (isEmpty(dataPath) || (typeof dataPath !== 'string')) { 10 | throw new Error(`[INVALID DATA PATH]: Expected data path instead of ${dataPath}`); 11 | } 12 | if (isEmpty(action) || ! action.hasOwnProperty('type')) { 13 | throw new Error('[INVALID ACTION]'); 14 | } 15 | if ( ! get(action, dataPath)) { 16 | console.warn(`No data found in action at ${dataPath}`); 17 | } 18 | return { 19 | ...action, 20 | meta: { 21 | ...action.meta, 22 | isEntmanAction: true, 23 | type: 'CREATE_ENTITIES', 24 | dataPath, 25 | schema, 26 | }, 27 | }; 28 | } 29 | 30 | 31 | export function updateEntities(schema, ids, dataPath, action, useDefault) { 32 | if ( ! schema || ! schema.key) { 33 | throw new Error(`[INVALID SCHEMA]: Entity schema expected instead of ${schema}`); 34 | } 35 | if ( ! ids) { 36 | throw new Error('[INVALID IDS]'); 37 | } 38 | if ( ! Array.isArray(ids)) { 39 | ids = [ids]; 40 | } 41 | if (isEmpty(dataPath) || (typeof dataPath !== 'string')) { 42 | throw new Error(`[INVALID DATA PATH]: Expected data path instead of ${dataPath}`); 43 | } 44 | if (isEmpty(action) || ! action.hasOwnProperty('type')) { 45 | throw new Error('[INVALID ACTION]'); 46 | } 47 | if ( ! get(action, dataPath)) { 48 | console.warn(`No data found in action at ${dataPath}`); 49 | } 50 | return { 51 | ...action, 52 | meta: { 53 | ...action.meta, 54 | isEntmanAction: true, 55 | type: 'UPDATE_ENTITIES', 56 | ids, 57 | dataPath, 58 | schema, 59 | useDefault, 60 | }, 61 | }; 62 | } 63 | 64 | 65 | export function updateEntityId(schema, oldId, newId, action) { 66 | if ( ! schema || ! schema.key) { 67 | throw new Error(`[INVALID SCHEMA]: Entity schema expected instead of ${schema}`); 68 | } 69 | if ( ! oldId) { 70 | throw new Error('[INVALID OLD ID]'); 71 | } 72 | if ( ! newId) { 73 | throw new Error('[INVALID NEW ID]'); 74 | } 75 | if (isEmpty(action) || ! action.hasOwnProperty('type')) { 76 | throw new Error('[INVALID ACTION]'); 77 | } 78 | return { 79 | ...action, 80 | meta: { 81 | ...action.meta, 82 | isEntmanAction: true, 83 | type: 'UPDATE_ENTITY_ID', 84 | schema, 85 | oldId, 86 | newId, 87 | }, 88 | }; 89 | } 90 | 91 | 92 | export function deleteEntities(schema, ids, action) { 93 | if ( ! schema || ! schema.key) { 94 | throw new Error(`[INVALID SCHEMA]: Entity schema expected instead of ${schema}`); 95 | } 96 | if ( ! ids) { 97 | throw new Error('[INVALID IDS]'); 98 | } 99 | if ( ! Array.isArray(ids)) { 100 | ids = [ids]; 101 | } 102 | if (isEmpty(action) || ! action.hasOwnProperty('type')) { 103 | throw new Error('[INVALID ACTION]'); 104 | } 105 | return { 106 | ...action, 107 | meta: { 108 | ...action.meta, 109 | isEntmanAction: true, 110 | type: 'DELETE_ENTITIES', 111 | ids, 112 | schema, 113 | }, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | if ( ! global._babelPolyfill) { 2 | require('babel-polyfill'); 3 | } 4 | 5 | 6 | export reducer from './reducer'; 7 | export * from './selectors'; 8 | export * from './helpers'; 9 | export * from './schema'; 10 | export middleware from './middleware'; 11 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import v4 from 'uuid/v4'; 3 | import { normalize } from 'normalizr'; 4 | import { batchActions } from 'redux-batched-actions'; 5 | 6 | import { getEntitiesSlice } from './selectors'; 7 | import { arrayFrom } from './utils'; 8 | 9 | 10 | // UTILS {{{ 11 | function normalizeData(schema, data) { 12 | const dataWithIds = arrayFrom(data).map((e) => e.id ? e : { ...e, id: v4() }); 13 | return normalize(dataWithIds, [ schema ]); 14 | } 15 | 16 | 17 | function extractEntities(entitiesAndKey) { 18 | return Object.keys(entitiesAndKey.entities).map((id) => ({ 19 | entity: { 20 | ...entitiesAndKey.entities[id], 21 | id: entitiesAndKey.entities[id].id ? entitiesAndKey.entities[id].id : id, 22 | }, 23 | key: entitiesAndKey.key, 24 | })); 25 | } 26 | 27 | 28 | // We try to dispatch the actions of the orignal entities 29 | // (the ones which schema was passed) first 30 | function sortMainFirst(main) { 31 | return (entity1, entity2) => { 32 | if (entity1.key === main.key) { 33 | return -1; 34 | } 35 | else if (entity2.key === main.key) { 36 | return 1; 37 | } 38 | return 0; 39 | }; 40 | } 41 | 42 | 43 | function getFromState(state, key, id) { 44 | return get(getEntitiesSlice(state), [key, id]); 45 | } 46 | // }}} 47 | 48 | 49 | 50 | function createCreateEntityActions(action, getState) { 51 | const dataPath = get(action, 'meta.dataPath'); 52 | const schema = get(action, 'meta.schema'); 53 | const skipNormalization = get(action, 'meta.skipNormalization'); 54 | const data = skipNormalization ? get(action, dataPath) : normalizeData(schema, get(action, dataPath)); 55 | return Object.keys(data.entities) 56 | .map((key) => ({ entities: data.entities[key], key })) 57 | .reduce((memo, entitiesAndKey) => [ ...memo, ...extractEntities(entitiesAndKey) ], []) 58 | .map((entity) => ({ ...entity, oldEntity: getFromState(getState(), entity.key, entity.entity.id) })) 59 | .sort(sortMainFirst(schema)) 60 | .map((payload) => { 61 | if (payload.oldEntity) { 62 | return { 63 | type: `@@entman/UPDATE_ENTITY_${payload.key.toUpperCase()}`, 64 | payload, 65 | }; 66 | } 67 | return { 68 | type: `@@entman/CREATE_ENTITY_${payload.key.toUpperCase()}`, 69 | payload, 70 | }; 71 | }); 72 | } 73 | 74 | 75 | function createUpdateEntityActions(action, getState) { 76 | const dataPath = get(action, 'meta.dataPath'); 77 | const schema = get(action, 'meta.schema'); 78 | const ids = get(action, 'meta.ids'); 79 | const useDefault = get(action, 'meta.useDefault'); 80 | const data = normalizeData(schema, ids.map((id) => ({ ...get(action, dataPath), id }))); 81 | return Object.keys(data.entities) 82 | .map((key) => ({ entities: data.entities[key], key })) 83 | .reduce((memo, entitiesAndKey) => [ ...memo, ...extractEntities(entitiesAndKey) ], []) 84 | .map((entity) => ({ ...entity, oldEntity: getFromState(getState(), entity.key, entity.entity.id) })) 85 | .sort(sortMainFirst(schema)) 86 | .map((payload) => { 87 | if (! payload.oldEntity) { 88 | return { 89 | type: `@@entman/CREATE_ENTITY_${payload.key.toUpperCase()}`, 90 | payload, 91 | }; 92 | } 93 | return { 94 | type: `@@entman/UPDATE_ENTITY_${payload.key.toUpperCase()}`, 95 | payload: { ...payload, useDefault }, 96 | }; 97 | }); 98 | } 99 | 100 | 101 | function createUpdateEntityIdActions(action, getState) { 102 | const schema = get(action, 'meta.schema'); 103 | const oldId = get(action, 'meta.oldId'); 104 | const newId = get(action, 'meta.newId'); 105 | return [{ 106 | type: `@@entman/UPDATE_ENTITY_ID_${schema.key.toUpperCase()}`, 107 | payload: { oldId, newId, oldEntity: getFromState(getState(), schema.key, oldId) }, 108 | }]; 109 | } 110 | 111 | 112 | function createDeleteEntityActions(action, getState) { 113 | const schema = get(action, 'meta.schema'); 114 | const ids = get(action, 'meta.ids'); 115 | // Do we cascade delete? 116 | return ids 117 | .map((id) => ({ id, key: schema.key })) 118 | .map((info) => ({ entity: getFromState(getState(), info.key, info.id), key: info.key })) 119 | .map((payload) => ({ 120 | type: `@@entman/DELETE_ENTITY_${schema.key.toUpperCase()}`, 121 | payload, 122 | })); 123 | } 124 | 125 | 126 | function processEntmanAction(store, next, action, enableBatching) { 127 | switch (action.meta.type) { 128 | case 'CREATE_ENTITIES': { 129 | if (enableBatching) { 130 | return next(batchActions(createCreateEntityActions(action, store.getState))); 131 | } 132 | return createCreateEntityActions(action, store.getState).forEach(next); 133 | } 134 | case 'UPDATE_ENTITIES': { 135 | if (enableBatching) { 136 | return next(batchActions(createUpdateEntityActions(action, store.getState))); 137 | } 138 | return createUpdateEntityActions(action, store.getState).forEach(next); 139 | } 140 | case 'UPDATE_ENTITY_ID': { 141 | if (enableBatching) { 142 | return next(batchActions(createUpdateEntityIdActions(action, store.getState))); 143 | } 144 | return createUpdateEntityIdActions(action, store.getState).forEach(next); 145 | } 146 | case 'DELETE_ENTITIES': { 147 | if (enableBatching) { 148 | return next(batchActions(createDeleteEntityActions(action, store.getState))); 149 | } 150 | return createDeleteEntityActions(action, store.getState).forEach(next); 151 | } 152 | default: { 153 | console.warn(`[ENTMAN] Unknown action type found ${action.meta.type}`); 154 | return next(action); 155 | } 156 | } 157 | } 158 | 159 | 160 | export default function entman(config={}) { 161 | const { enableBatching } = config; 162 | return (store) => (next) => (action) => { 163 | if ( ! get(action, 'meta.isEntmanAction', false)) { 164 | return next(action); 165 | } 166 | next(action); 167 | return processEntmanAction(store, next, action, enableBatching); 168 | }; 169 | } 170 | -------------------------------------------------------------------------------- /src/reducer/create-reactions.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import mapValues from 'lodash/mapValues'; 3 | import isEqual from 'lodash/isEqual'; 4 | 5 | import { arrayFrom } from '../utils'; 6 | 7 | 8 | function addToManyProperty(state, action, relation) { 9 | const { entity: foreignEntity } = action.payload; 10 | const { through, foreign } = relation; 11 | const entityToUpdate = get(state, get(foreignEntity, foreign)); 12 | if ( ! entityToUpdate) { 13 | // Don't do anything if the parent entity doesn't exist 14 | return state; 15 | } 16 | if (get(entityToUpdate, through, []).find((id) => id == foreignEntity.id)) { 17 | return state; // Don't do anything if the entity is already on the state 18 | } 19 | return { 20 | ...state, 21 | [entityToUpdate.id]: { 22 | ...entityToUpdate, 23 | [through]: [ 24 | ...get(entityToUpdate, through, []), 25 | foreignEntity.id, 26 | ], 27 | }, 28 | }; 29 | } 30 | 31 | function deleteFromManyProperty(state, action, relation) { 32 | const { entity: foreignEntity } = action.payload; 33 | const { through, foreign } = relation; 34 | const entityToUpdate = get(state, get(foreignEntity, foreign)); 35 | if ( ! entityToUpdate) { 36 | // Don't do anything if the parent entity doesn't exist 37 | return state; 38 | } 39 | return { 40 | ...state, 41 | [entityToUpdate.id]: { 42 | ...entityToUpdate, 43 | [through]: get(entityToUpdate, through, []).filter((id) => id != foreignEntity.id) 44 | }, 45 | }; 46 | } 47 | 48 | function updateRelation(state, action, relation) { 49 | const { entity: foreignEntity, oldEntity: oldForeignEntity } = action.payload; 50 | const { through, foreign } = relation; 51 | if (get(foreignEntity, foreign) === undefined || isEqual(get(foreignEntity, foreign), get(oldForeignEntity, foreign))) { 52 | return state; // No need to update because the prop has not been updated 53 | } 54 | const newParentEntitiesIds = arrayFrom(get(foreignEntity, foreign)); 55 | const oldParentEntitiesIds = arrayFrom(get(oldForeignEntity, foreign)); 56 | return mapValues(state, (entity) => { 57 | const id = entity.id; 58 | if (newParentEntitiesIds.includes(id) && get(entity, through).find((id) => id == foreignEntity.id) === undefined) { 59 | return { 60 | ...entity, 61 | [through]: [ 62 | ...get(entity, through, []), 63 | foreignEntity.id, 64 | ], 65 | }; 66 | } 67 | if (oldParentEntitiesIds.includes(id) && ! newParentEntitiesIds.includes(entity.id)) { 68 | return { 69 | ...entity, 70 | [through]: get(entity, through, []).filter((id) => id != foreignEntity.id) 71 | }; 72 | } 73 | return entity; 74 | }); 75 | } 76 | 77 | function updateRelatedId(state, action, relation) { 78 | const { newId, oldId } = action.payload; 79 | const { isMany, through } = relation; 80 | if (isMany) { 81 | return mapValues(state, (entity) => ({ 82 | ...entity, 83 | [through]: get(entity, through, []).map((id) => id == oldId ? newId : id), 84 | })); 85 | } 86 | else { 87 | return mapValues(state, (entity) => ({ 88 | ...entity, 89 | [through]: get(entity, through) == oldId ? newId : get(entity, through), 90 | })); 91 | } 92 | } 93 | 94 | function deleteOneProperty(state, action, relation) { 95 | const { entity: foreignEntity } = action.payload; 96 | const { through } = relation; 97 | return mapValues(state, (entity) => ({ 98 | ...entity, 99 | [through]: get(entity, through) == foreignEntity.id ? null : get(entity, through), 100 | })); 101 | } 102 | 103 | 104 | function createOneRelationReactions(relation) { 105 | const { to } = relation; 106 | return { 107 | [`@@entman/DELETE_ENTITY_${to.toUpperCase()}`]: (state, action) => { 108 | return deleteOneProperty(state, action, relation); 109 | }, 110 | [`@@entman/UPDATE_ENTITY_ID_${to.toUpperCase()}`]: (state, action) => { 111 | return updateRelatedId(state, action, relation); 112 | }, 113 | }; 114 | } 115 | 116 | 117 | function createManyRelationReactions(relation) { 118 | const { to } = relation; 119 | return { 120 | [`@@entman/CREATE_ENTITY_${to.toUpperCase()}`]: (state, action) => { 121 | return addToManyProperty(state, action, relation); 122 | }, 123 | [`@@entman/DELETE_ENTITY_${to.toUpperCase()}`]: (state, action) => { 124 | return deleteFromManyProperty(state, action, relation); 125 | }, 126 | [`@@entman/UPDATE_ENTITY_${to.toUpperCase()}`]: (state, action) => { 127 | return updateRelation(state, action, relation); 128 | }, 129 | [`@@entman/UPDATE_ENTITY_ID_${to.toUpperCase()}`]: (state, action) => { 130 | return updateRelatedId(state, action, relation); 131 | } 132 | }; 133 | } 134 | 135 | 136 | // The entity in state reacts to the entity in relation 137 | function createReactionsToRelation(relation) { 138 | if (relation.isMany) { // relation is going to be an array (e.g. users) 139 | return createManyRelationReactions(relation); 140 | } 141 | else { 142 | return createOneRelationReactions(relation); 143 | } 144 | } 145 | 146 | 147 | export default function createReactions(relations) { 148 | return relations 149 | .map(createReactionsToRelation) 150 | .reduce((memo, reactions) => ({ ...memo, ...reactions }), {}); 151 | } 152 | -------------------------------------------------------------------------------- /src/reducer/entity.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import isPlainObject from 'lodash/isPlainObject'; 3 | 4 | import { update, defaultTo } from '../utils'; 5 | import createReactions from './create-reactions'; 6 | 7 | 8 | function createEntity(state, action) { 9 | const { entity } = action.payload; 10 | return { ...state, [entity.id]: entity }; 11 | } 12 | 13 | 14 | function updateEntity(state, action) { 15 | const { entity, useDefault } = action.payload; 16 | const data = omit(entity, 'id'); 17 | return { 18 | ...state, 19 | [entity.id]: useDefault ? defaultTo(state[entity.id], data) : update(state[entity.id], data), 20 | }; 21 | } 22 | 23 | 24 | function updateEntityId(state, action) { 25 | const { oldId, newId } = action.payload; 26 | return { 27 | ...omit(state, oldId), 28 | [newId]: { ...state[oldId], id: newId }, 29 | }; 30 | } 31 | 32 | 33 | function deleteEntity(state, action) { 34 | const { entity } = action.payload; 35 | return omit(state, entity.id); 36 | } 37 | 38 | 39 | export default function createEntityReducer(schema, initialState={}) { 40 | const reactions = createReactions(schema.getRelations()); 41 | if ( ! isPlainObject(initialState)) { 42 | throw new Error(`Invalid initial state for ${schema.key}. Initial state of an entity should be a plain object`); 43 | } 44 | return (state=initialState, action) => { 45 | switch (action.type) { 46 | case `@@entman/CREATE_ENTITY_${schema.key.toUpperCase()}`: { 47 | return createEntity(state, action); 48 | } 49 | case `@@entman/UPDATE_ENTITY_${schema.key.toUpperCase()}`: { 50 | return updateEntity(state, action); 51 | } 52 | case `@@entman/DELETE_ENTITY_${schema.key.toUpperCase()}`: { 53 | return deleteEntity(state, action); 54 | } 55 | case `@@entman/UPDATE_ENTITY_ID_${schema.key.toUpperCase()}`: { 56 | return updateEntityId(state, action); 57 | } 58 | default: { 59 | const reaction = reactions[action.type]; 60 | return (typeof reaction === 'function') ? reaction(state, action) : state; 61 | } 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/reducer/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import isEmpty from 'lodash/isEmpty'; 3 | import { enableBatching } from 'redux-batched-actions'; 4 | 5 | import createEntityReducer from './entity'; 6 | 7 | 8 | function createReducer(schemas, initialState={}) { 9 | const entitiesReducers = Object.keys(schemas).reduce((memo, k) => ({ 10 | ...memo, 11 | [k]: createEntityReducer(schemas[k], initialState[k]), 12 | }), {}); 13 | return combineReducers(entitiesReducers); 14 | } 15 | 16 | 17 | export default function entities(schemas, initialState) { 18 | if (isEmpty(schemas)) { 19 | throw new Error('[INVALID SCHEMAS]'); 20 | } 21 | return enableBatching(createReducer(schemas, initialState)); 22 | } 23 | -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | import isEmpty from 'lodash/isEmpty'; 2 | import omit from 'lodash/omit'; 3 | import { schema } from 'normalizr'; 4 | 5 | 6 | function getRelatedThrough(entity1, entity2) { 7 | const entity1Schema = entity1.schema; 8 | const entity2Schema = entity2; 9 | const t = Object.keys(entity1Schema).find((prop) => { 10 | if (prop.startsWith('_')) { 11 | return false; 12 | } 13 | const relation = entity1Schema[prop]; 14 | if (relation.key) { 15 | return relation.key === entity2Schema.key; 16 | } 17 | else if (Array.isArray(relation)) { 18 | return relation[0].key === entity2Schema.key; 19 | } 20 | return false; 21 | }); 22 | return t; 23 | } 24 | 25 | 26 | function createSchemaDefinition(config, bag) { 27 | const definition = Object.keys(config.attributes).reduce((memo, a) => { 28 | const attribute = config.attributes[a]; 29 | if (typeof attribute === 'function') { // Treat it like a computed property 30 | return { ...memo, _computed: { ...memo._computed, [a]: attribute } }; 31 | } 32 | else if (typeof attribute === 'string') { // Single reference to another schema 33 | return { ...memo, [a]: bag[attribute] }; 34 | } 35 | else if (attribute.isArray) { // Array reference to another schema 36 | return { ...memo, [a]: [ bag[attribute.relatedSchema] ] }; 37 | } 38 | return memo; // Should never come to here 39 | }, {}); 40 | return { ...definition, ...omit(config, 'attributes') }; 41 | } 42 | 43 | 44 | function extractRelatedEntities(attributes) { 45 | return Object.keys(attributes).reduce((memo, a) => { 46 | const attribute = attributes[a]; 47 | if (typeof attribute === 'string') { // Single reference to another schema 48 | return [ ...memo, { prop: a, entity: attribute, isArray: false } ]; 49 | } 50 | else if (attribute.isArray) { // Array reference to another schema 51 | return [ ...memo, { prop: a, entity: attribute.relatedSchema, isArray: true } ]; 52 | } 53 | return memo; 54 | }, []); 55 | } 56 | 57 | 58 | function getRelations(schema, relatedEntities, bag) { 59 | return () => relatedEntities.map((relatedEntity) => ({ 60 | to: relatedEntity.entity, 61 | through: relatedEntity.prop, 62 | foreign: getRelatedThrough(bag[relatedEntity.entity], schema), 63 | isMany: relatedEntity.isArray, 64 | })); 65 | } 66 | 67 | 68 | function generateSchema(schema, bag) { 69 | const finalSchema = bag[schema.name]; 70 | finalSchema.define(createSchemaDefinition(schema.config, bag)); 71 | 72 | Object.defineProperty(finalSchema, 'getRelations', { 73 | enumerable: false, 74 | value: getRelations(finalSchema, extractRelatedEntities(schema.config.attributes), bag), 75 | }); 76 | 77 | return finalSchema; 78 | } 79 | 80 | 81 | 82 | export function defineSchema(name, config={}) { 83 | if (isEmpty(name)) { 84 | throw new Error('[INVALID NAME]'); 85 | } 86 | if ((typeof config !== 'object') && config) { 87 | throw new Error('[INVALID CONFIG]'); 88 | } 89 | return { 90 | name, 91 | config: { 92 | attributes: config.attributes ? config.attributes : {}, 93 | options: config.options ? config.attributes : {}, 94 | }, 95 | }; 96 | } 97 | 98 | 99 | export function hasMany(schema) { 100 | if (isEmpty(schema)) { 101 | throw new Error('[INVALID SCHEMA]'); 102 | } 103 | if (typeof schema !== 'string' && ! schema.hasOwnProperty('name')) { 104 | throw new Error('[INVALID SCHEMA]'); 105 | } 106 | return { 107 | relatedSchema: schema.hasOwnProperty('name') ? schema.name : schema, 108 | isArray: true, 109 | }; 110 | } 111 | 112 | 113 | export function generateSchemas(schemas) { 114 | if (isEmpty(schemas)) { 115 | throw new Error('[INVALID SCHEMAS]'); 116 | } 117 | if ( ! Array.isArray(schemas)) { 118 | throw new Error('[INVALID SCHEMAS]'); 119 | } 120 | const schemasBag = schemas.reduce((bag, s) => ({ 121 | ...bag, 122 | [s.name]: new schema.Entity(s.name, {}, s.config.options), 123 | }), {}); 124 | const t = schemas.reduce((result, s) => ({ 125 | ...result, 126 | [s.name]: generateSchema(s, schemasBag), 127 | }), {}); 128 | return t; 129 | } 130 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | import pickBy from 'lodash/pickBy'; 2 | import isEmpty from 'lodash/isEmpty'; 3 | import values from 'lodash/values'; 4 | import { denormalize } from 'entman-denormalizr'; 5 | 6 | 7 | export function getEntitiesSlice(state) { 8 | return state.entities; 9 | } 10 | 11 | 12 | export function getEntities(state, schema, ids, raw) { 13 | const key = schema.key; 14 | const entitiesState = getEntitiesSlice(state); 15 | const entities = ids ? 16 | values(entitiesState[key]).filter(e => ids.includes(e.id)) : values(entitiesState[key]); 17 | 18 | if (raw) { 19 | return entities; 20 | } 21 | 22 | return entities.map(e => denormalize(e, entitiesState, schema)); 23 | } 24 | 25 | 26 | export function getEntitiesBy(state, schema, by={}, raw) { 27 | const byKey = Object.keys(by)[0]; 28 | const value = by[byKey]; 29 | const key = schema.key; 30 | const entitiesState = getEntitiesSlice(state); 31 | const entities = values(pickBy(entitiesState[key], e => e[byKey] === value)); 32 | 33 | if (raw) { 34 | return entities; 35 | } 36 | 37 | return entities.map(e => denormalize(e, entitiesState, schema)); 38 | } 39 | 40 | 41 | export function getEntity(state, schema, id, raw) { 42 | if ( ! id) throw new Error('Required param `id` is missing'); 43 | const key = schema.key; 44 | const entitiesState = getEntitiesSlice(state); 45 | const entities = entitiesState[key]; 46 | if (isEmpty(entities)) return null; 47 | const entity = entities[id]; 48 | 49 | if (raw) { 50 | return entity; 51 | } 52 | 53 | return denormalize(entity, entitiesState, schema); 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep'; 2 | import defaultsDeep from 'lodash/defaultsDeep'; 3 | import set from 'lodash/set'; 4 | import isPlainObject from 'lodash/isPlainObject'; 5 | 6 | 7 | export function flatten(obj, parentPath) { 8 | return Object.keys(obj || {}).reduce((result, k) => { 9 | if ( ! obj.hasOwnProperty(k)) return result; 10 | const currentPath = parentPath ? parentPath + '.' + k : k; 11 | const currentProp = obj[k]; 12 | 13 | //if (Array.isArray(currentProp)) { 14 | //const arrayResult = currentProp.map((value, i) => { 15 | //const arrayPath = `${currentPath}[${i}]`; 16 | //if (isPlainObject(value)) return flatten(value, arrayPath); 17 | //return { [arrayPath] : value }; 18 | //}); 19 | //return Object.assign({}, result, ...arrayResult); 20 | //} 21 | if (isPlainObject(currentProp)) { 22 | return { 23 | ...result, 24 | ...flatten(currentProp, currentPath), 25 | }; 26 | } 27 | 28 | return { 29 | ...result, 30 | [currentPath]: currentProp, 31 | }; 32 | }, {}); 33 | } 34 | 35 | 36 | export function defaultTo(obj, defaults) { 37 | return defaultsDeep(cloneDeep(obj), defaults); 38 | } 39 | 40 | 41 | export function update(obj={}, newData) { 42 | const flattenedData = flatten(newData); 43 | return Object.keys(flattenedData).reduce((result, k) => { 44 | if (flattenedData[k] === undefined) { 45 | return result; 46 | } 47 | return set(result, k, flattenedData[k]); 48 | }, cloneDeep(obj)); 49 | } 50 | 51 | 52 | export function arrayFrom(value) { 53 | return Array.isArray(value) ? value : [value]; 54 | } 55 | 56 | 57 | export function log(value) { 58 | console.log(value); 59 | return value; 60 | } 61 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import './unit'; 3 | import './integration'; 4 | -------------------------------------------------------------------------------- /test/integration/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | createEntities, 3 | updateEntities, 4 | updateEntityId, 5 | deleteEntities, 6 | } from '../../lib'; 7 | import schemas from './schemas'; 8 | 9 | 10 | export function receiveGroups(groups) { 11 | return createEntities(schemas.Group, 'payload.groups', { 12 | type: 'RECEIVE_GROUPS', 13 | payload: { groups }, 14 | }); 15 | } 16 | 17 | 18 | export function createGroup(group) { 19 | return createEntities(schemas.Group, 'payload.group', { 20 | type: 'CREATE_GROUP', 21 | payload: { group }, 22 | }); 23 | } 24 | 25 | 26 | export function createUser(user) { 27 | return createEntities(schemas.User, 'payload.user', { 28 | type: 'CREATE_USER', 29 | payload: { user }, 30 | }); 31 | } 32 | 33 | 34 | export function createTask(task) { 35 | return createEntities(schemas.Task, 'payload.task', { 36 | type: 'CREATE_TASK', 37 | payload: { task }, 38 | }); 39 | } 40 | 41 | 42 | export function updateGroup(id, data) { 43 | return updateEntities(schemas.Group, id, 'payload.data', { 44 | type: 'UPDATE_GROUP', 45 | payload: { data }, 46 | }); 47 | } 48 | 49 | 50 | export function updateUser(id, data) { 51 | return updateEntities(schemas.User, id, 'payload.data', { 52 | type: 'UPDATE_USER', 53 | payload: { data }, 54 | }); 55 | } 56 | 57 | 58 | export function updateTask(id, data) { 59 | return updateEntities(schemas.Task, id, 'payload.data', { 60 | type: 'UPDATE_TASK', 61 | payload: { data }, 62 | }); 63 | } 64 | 65 | 66 | export function deleteGroup(id) { 67 | return deleteEntities(schemas.Group, id, { 68 | type: 'DELETE_GROUP', 69 | }); 70 | } 71 | 72 | 73 | export function deleteUser(id) { 74 | return deleteEntities(schemas.User, id, { 75 | type: 'DELETE_USER', 76 | }); 77 | } 78 | 79 | 80 | export function updateUserId(oldId, newId) { 81 | return updateEntityId(schemas.User, oldId, newId, { 82 | type: 'UPDATE_USER_ID', 83 | }); 84 | } 85 | 86 | 87 | export function updateGroupId(oldId, newId) { 88 | return updateEntityId(schemas.Group, oldId, newId, { 89 | type: 'UPDATE_GROUP_ID', 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /test/integration/full.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | getEntitiesSlice, 5 | } from 'index'; 6 | 7 | import api from './mock-api'; 8 | import { 9 | receiveGroups, 10 | createUser, 11 | updateGroup, 12 | updateUser, 13 | deleteGroup, 14 | deleteUser, 15 | updateUserId, 16 | updateGroupId, 17 | } from './actions'; 18 | import store from './store'; 19 | 20 | 21 | describe('FULL EXAMPLE', function () { 22 | 23 | before(function () { 24 | }); 25 | 26 | describe('after initialization', function () { 27 | it('the store should contain an state with empty entities', function () { 28 | const state = getEntitiesSlice(store.getState()); 29 | expect(state).to.contain.keys(['Group', 'User', 'Task']); 30 | expect(state.Group).to.be.empty; 31 | expect(state.User).to.be.empty; 32 | expect(state.Task).to.be.empty; 33 | }); 34 | }); 35 | 36 | describe('when adding groups to the estate', function () { 37 | let state; 38 | before(function () { 39 | const groups = api.groups.findAll(); 40 | const action = receiveGroups(groups); 41 | store.dispatch(action); 42 | state = getEntitiesSlice(store.getState()); 43 | }); 44 | it('the reducer should return the new state with the groups on it', function () { 45 | expect(state.Group[1]).to.exist; 46 | expect(state.Group[2]).to.exist; 47 | }); 48 | it('the new state should contain also related entities', function () { 49 | expect(state.User[1]).to.exist; 50 | expect(state.User[2]).to.exist; 51 | expect(state.User[3]).to.exist; 52 | expect(state.User[4]).to.exist; 53 | }); 54 | }); 55 | 56 | describe('when adding a new user', function () { 57 | let state; 58 | before(function () { 59 | const newUser = { 60 | id: 123, 61 | name: 'Fienhard', 62 | group: 1, 63 | tasks: [ 64 | { 65 | id: 6, 66 | name: 'Task 6', 67 | users: [ 123 ], 68 | }, 69 | { 70 | id: 5, 71 | name: 'Task 52', 72 | users: [ 123, 4 ], 73 | }, 74 | ], 75 | }; 76 | const action = createUser(newUser); 77 | store.dispatch(action); 78 | state = getEntitiesSlice(store.getState()); 79 | }); 80 | it('the new state should contain the new user', function () { 81 | expect(state.User[123]).to.exist; 82 | }); 83 | it('the group should be updated with the new user', function () { 84 | expect(state.Group[1].users).to.include(123); 85 | }); 86 | describe('if the new user contained an embedded entity', function () { 87 | it('add it to the store if it wasn\'t already there', function () { 88 | expect(state.Task[6]).to.exist; 89 | }); 90 | it('update the entity in the store if it was already there', function () { 91 | expect(state.Task[5].name).to.equal('Task 52'); 92 | }); 93 | }); 94 | }); 95 | 96 | describe('when updating a group', function () { 97 | let state; 98 | describe('if we\'re updating a single property (no array, no relation)', function () { 99 | before(function () { 100 | const action = updateGroup(1, { name: 'New Test Group' }); 101 | store.dispatch(action); 102 | state = getEntitiesSlice(store.getState()); 103 | }); 104 | it('the property of the group should be updated in the state', function () { 105 | expect(state.Group[1].name).to.equal('New Test Group'); 106 | }); 107 | }); 108 | }); 109 | 110 | describe('when updating an user', function () { 111 | let state; 112 | before(function () { 113 | const action = updateUser(1, { name: 'New User Name', group: 2 }); 114 | store.dispatch(action); 115 | state = getEntitiesSlice(store.getState()); 116 | }); 117 | it('the user should have its properties updated', function () { 118 | expect(state.User[1].name).to.equal('New User Name'); 119 | }); 120 | describe('if updating the group of the user', function () { 121 | it('the original group should not contain the user in the users list', function () { 122 | expect(state.Group[1].users).to.not.include(1); 123 | }); 124 | it('the new group should contain the user in the users list', function () { 125 | expect(state.Group[2].users).to.include(1); 126 | }); 127 | }); 128 | }); 129 | 130 | describe('when updating the tasks of an user', function () { 131 | let state; 132 | before(function () { 133 | // add user to task 3 134 | const action = updateUser(1, { tasks: [2, 3] }); // we need to keep the old tasks as well 135 | store.dispatch(action); 136 | state = getEntitiesSlice(store.getState()); 137 | }); 138 | it('the user should have its tasks updated', function () { 139 | expect(state.User[1].tasks).to.not.include(1); 140 | expect(state.User[1].tasks).to.include(2); 141 | expect(state.User[1].tasks).to.include(3); 142 | }); 143 | it('the tasks should have its respective users updated', function () { 144 | expect(state.Task[1].users).to.not.include(1); 145 | expect(state.Task[2].users).to.include(1); 146 | expect(state.Task[3].users).to.include(1); 147 | }); 148 | }); 149 | 150 | describe('when deleting an user', function () { 151 | let state; 152 | before(function () { 153 | const action = deleteUser(123); 154 | store.dispatch(action); 155 | state = getEntitiesSlice(store.getState()); 156 | }); 157 | it('the entity should be removed from the state', function () { 158 | expect(state.User[123]).to.not.exist; 159 | }); 160 | it('the related group should be updated to remove the reference to the user', function () { 161 | expect(state.Group[2].users).to.not.include('123'); 162 | }); 163 | }); 164 | 165 | describe('when deleting a group', function () { 166 | let state; 167 | before(function () { 168 | const action = deleteGroup(1); 169 | store.dispatch(action); 170 | state = getEntitiesSlice(store.getState()); 171 | }); 172 | it('the entity should be removed from the state', function () { 173 | expect(state.Group[1]).to.not.exist; 174 | }); 175 | it('the associated entities should set the related property to null', function () { 176 | expect(state.User[2].group).to.be.null; 177 | }); 178 | it.skip('or do we cascade related entities?', function () { 179 | }); 180 | }); 181 | 182 | describe('when updating the id of an user', function () { 183 | let state; 184 | before(function () { 185 | const action = updateUserId(1, 145); 186 | store.dispatch(action); 187 | state = getEntitiesSlice(store.getState()); 188 | }); 189 | it('the id of the user in the store should have changed', function () { 190 | expect(state.User[1]).to.not.exist; 191 | expect(state.User[145]).to.exist; 192 | }); 193 | it('the related group should also change the id in the users array', function () { 194 | expect(state.Group[2].users).to.not.contain('1'); 195 | expect(state.Group[2].users).to.contain(145); 196 | }); 197 | }); 198 | 199 | describe('when updating the id of a group', function () { 200 | let state; 201 | before(function () { 202 | const action = updateGroupId(2, 456); 203 | store.dispatch(action); 204 | state = getEntitiesSlice(store.getState()); 205 | }); 206 | it('the id of the group in the store should have changed', function () { 207 | expect(state.Group[2]).to.not.exist; 208 | expect(state.Group[456]).to.exist; 209 | }); 210 | it('the related users should also update the id of the group', function () { 211 | expect(state.User[3].group).to.equal(456); 212 | expect(state.User[4].group).to.equal(456); 213 | expect(state.User[145].group).to.equal(456); 214 | }); 215 | }); 216 | 217 | describe.skip('when using selectors to retrieve a group', function () { 218 | it('the group should have users populated', function () { 219 | }); 220 | }); 221 | 222 | }); 223 | -------------------------------------------------------------------------------- /test/integration/index.js: -------------------------------------------------------------------------------- 1 | describe('INTEGRATION TESTS', function () { 2 | require('./full'); 3 | }); 4 | -------------------------------------------------------------------------------- /test/integration/mock-api.js: -------------------------------------------------------------------------------- 1 | let groups, users, tasks; 2 | 3 | 4 | users = () => [ 5 | { 6 | id: 1, 7 | name: 'Lars', 8 | group: 1, 9 | tasks: [ tasks()[0], tasks()[1] ], 10 | }, 11 | { 12 | id: 2, 13 | name: 'Grishan', 14 | group: 1, 15 | tasks: [ tasks()[2] ], 16 | }, 17 | { 18 | id: 3, 19 | name: 'Lars', 20 | group: 2, 21 | tasks: [ tasks()[3] ], 22 | }, 23 | { 24 | id: 4, 25 | name: 'Grishan', 26 | group: 2, 27 | tasks: [ tasks()[4] ], 28 | }, 29 | ]; 30 | 31 | 32 | groups = () => [ 33 | { 34 | id: 1, 35 | name: 'Test Group', 36 | users: [ users()[0], users()[1] ], 37 | }, 38 | { 39 | id: 2, 40 | name: 'Test Group 2', 41 | users: [ users()[2], users()[3] ], 42 | }, 43 | ]; 44 | 45 | 46 | tasks = () => [ 47 | { 48 | id: 1, 49 | name: 'Task 1', 50 | users: [ 1 ], 51 | }, 52 | { 53 | id: 2, 54 | name: 'Task 2', 55 | users: [ 1 ], 56 | }, 57 | { 58 | id: 3, 59 | name: 'Task 3', 60 | users: [ 2 ], 61 | }, 62 | { 63 | id: 4, 64 | name: 'Task 4', 65 | users: [ 3 ], 66 | }, 67 | { 68 | id: 5, 69 | name: 'Task 5', 70 | users: [ 4 ], 71 | }, 72 | ]; 73 | 74 | 75 | export default { 76 | groups: { 77 | findAll() { 78 | return groups(); 79 | }, 80 | find(id) { 81 | return groups().find((x) => x.id == id); 82 | }, 83 | }, 84 | users: { 85 | findAll() { 86 | return users(); 87 | }, 88 | find(id) { 89 | return users().find((x) => x.id == id); 90 | }, 91 | }, 92 | tasks: { 93 | findAll() { 94 | return tasks(); 95 | }, 96 | find(id) { 97 | return tasks().find((x) => x.id == id); 98 | }, 99 | }, 100 | }; 101 | -------------------------------------------------------------------------------- /test/integration/schemas.js: -------------------------------------------------------------------------------- 1 | import { defineSchema, hasMany, generateSchemas } from '../../lib'; 2 | 3 | 4 | const Group = defineSchema('Group', { 5 | attributes: { 6 | users: hasMany('User'), 7 | 8 | getNumberOfUsers() { 9 | return this.users.length; 10 | }, 11 | }, 12 | }); 13 | 14 | 15 | const User = defineSchema('User', { 16 | attributes: { 17 | group: 'Group', 18 | tasks: hasMany('Task'), 19 | }, 20 | }); 21 | 22 | 23 | const Task = defineSchema('Task', { 24 | attributes: { 25 | users: hasMany('User'), 26 | }, 27 | }); 28 | 29 | 30 | export default generateSchemas([ Group, User, Task ]); 31 | -------------------------------------------------------------------------------- /test/integration/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; 2 | 3 | import { 4 | reducer as entities, 5 | middleware as entman, 6 | } from '../../lib'; 7 | import schemas from './schemas'; 8 | 9 | 10 | const reducer = combineReducers({ entities: entities(schemas) }); 11 | 12 | 13 | export default createStore( 14 | reducer, 15 | compose( 16 | applyMiddleware(entman({ enableBatching: true })), 17 | window.devToolsExtension ? window.devToolsExtension() : (f) => f, 18 | ), 19 | ); 20 | -------------------------------------------------------------------------------- /test/unit/actions.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { normalize, arrayOf } from 'normalizr'; 3 | 4 | import { defineSchema, generateSchemas } from 'schema'; 5 | import { 6 | createEntity, 7 | CREATE_ENTITY, 8 | updateEntity, 9 | UPDATE_ENTITY, 10 | updateEntities, 11 | UPDATE_ENTITIES, 12 | deleteEntity, 13 | DELETE_ENTITY, 14 | updateEntityId, 15 | UPDATE_ENTITY_ID, 16 | } from 'actions'; 17 | 18 | 19 | describe('@Actions', function () { 20 | let schemas; 21 | before(function () { 22 | const cart = defineSchema('Cart'); 23 | schemas = generateSchemas([cart]); 24 | }); 25 | describe('createEntity(schema, data)', function () { 26 | it('should return an action object of type CREATE_ENTITY', function () { 27 | const { Cart } = schemas; 28 | const result = createEntity(Cart, {}); 29 | expect(result.type).to.equal(CREATE_ENTITY); 30 | }); 31 | it('should include a meta property with isEntityAction set to true', function () { 32 | const { Cart } = schemas; 33 | const result = createEntity(Cart, {}); 34 | expect(result.meta.isEntityAction).to.be.true; 35 | }); 36 | it('should include the key of the entity in the payload', function () { 37 | const { Cart } = schemas; 38 | const data = { foo: 'bar' }; 39 | const result = createEntity(Cart, data); 40 | expect(result.payload.key).to.equal('Cart'); 41 | }); 42 | it('should include the data normalized in the payload', function () { 43 | const { Cart } = schemas; 44 | const data = { foo: 'bar', id: 1 }; 45 | const result = createEntity(Cart, data); 46 | expect(result.payload.data).to.deep.equal(normalize(data, Cart)); 47 | }); 48 | it('should generate an id if it\'s not present in the data', function () { 49 | const { Cart } = schemas; 50 | const data = { foo: 'bar' }; 51 | const result = createEntity(Cart, data); 52 | expect(result.payload._rawData).to.contain.key('id'); 53 | }); 54 | it('should keep the id present in the data', function () { 55 | const { Cart } = schemas; 56 | const id = 1; 57 | const data = { foo: 'bar', id }; 58 | const result = createEntity(Cart, data); 59 | expect(result.payload._rawData.id).to.equal(id); 60 | }); 61 | }); 62 | describe('updateEntity(schema, id, data)', function () { 63 | it('should return an action object of type UPDATE_ENTITY', function () { 64 | const { Cart } = schemas; 65 | const result = updateEntity(Cart, 1231, { foo: 'bar' }); 66 | expect(result.type).to.equal(UPDATE_ENTITY); 67 | }); 68 | it('should include a meta property with isEntityAction set to true', function () { 69 | const { Cart } = schemas; 70 | const result = updateEntity(Cart, 1231, {}); 71 | expect(result.meta.isEntityAction).to.be.true; 72 | }); 73 | it('should include the the key of the entity in the payload', function () { 74 | const { Cart } = schemas; 75 | const result = updateEntity(Cart, 123, { foo: 'bar' }); 76 | expect(result.payload.key).to.equal('Cart'); 77 | }); 78 | it('should include the id of the entity in the payload', function () { 79 | const { Cart } = schemas; 80 | const id = 1231; 81 | const result = updateEntity(Cart, id, { foo: 'bar' }); 82 | expect(result.payload.id).to.equal(id); 83 | }); 84 | it('should include the data in the payload', function () { 85 | const { Cart } = schemas; 86 | const id = 123; 87 | const data = { foo: 'bar' }; 88 | const result = updateEntity(Cart, id, data); 89 | expect(result.payload.data).to.deep.equal(normalize({ id, ...data }, Cart)); 90 | }); 91 | }); 92 | describe('updateEntities(schema, id, data)', function () { 93 | it('should return an action object of type UPDATE_ENTITIES', function () { 94 | const { Cart } = schemas; 95 | const result = updateEntities(Cart, [ 1231 ], { foo: 'bar' }); 96 | expect(result.type).to.equal(UPDATE_ENTITIES); 97 | }); 98 | it('should include a meta property with isEntityAction set to true', function () { 99 | const { Cart } = schemas; 100 | const result = updateEntities(Cart, [ 1231 ], {}); 101 | expect(result.meta.isEntityAction).to.be.true; 102 | }); 103 | it('should include the the key of the entities in the payload', function () { 104 | const { Cart } = schemas; 105 | const result = updateEntities(Cart, [ 123 ], { foo: 'bar' }); 106 | expect(result.payload.key).to.equal('Cart'); 107 | }); 108 | it('should include the ids of the entities in the payload', function () { 109 | const { Cart } = schemas; 110 | const ids = [ 1231 ]; 111 | const result = updateEntities(Cart, ids, { foo: 'bar' }); 112 | expect(result.payload.ids).to.deep.equal(ids); 113 | }); 114 | it('should include the data in the payload as an array', function () { 115 | const { Cart } = schemas; 116 | const ids = [ 123 ]; 117 | const data = { foo: 'bar' }; 118 | const result = updateEntities(Cart, ids, data); 119 | expect(result.payload.data).to.deep.equal(normalize([{ id: 123, ...data }], arrayOf(Cart))); 120 | }); 121 | }); 122 | describe('updateEntityId(schema, newId, oldId)', function () { 123 | it('should return an action object of type UPDATE_ENTITY_ID', function () { 124 | const { Cart } = schemas; 125 | const result = updateEntityId(Cart, 1, 2); 126 | expect(result.type).to.equal(UPDATE_ENTITY_ID); 127 | }); 128 | it('should include a meta property with isEntityAction set to true', function () { 129 | const { Cart } = schemas; 130 | const result = updateEntityId(Cart, 1, 2); 131 | expect(result.meta.isEntityAction).to.be.true; 132 | }); 133 | it('should include the key of the entity in the payload', function () { 134 | const { Cart } = schemas; 135 | const result = updateEntityId(Cart, 1, 2); 136 | expect(result.payload.key).to.equal('Cart'); 137 | }); 138 | it('should include the oldId of the entity in the payload', function () { 139 | const { Cart } = schemas; 140 | const oldId = 1; 141 | const result = updateEntityId(Cart, oldId, 2); 142 | expect(result.payload.oldId).to.equal(oldId); 143 | }); 144 | it('should include the newId of the entity in the payload', function () { 145 | const { Cart } = schemas; 146 | const newId = 2; 147 | const result = updateEntityId(Cart, 1, newId); 148 | expect(result.payload.newId).to.equal(newId); 149 | }); 150 | }); 151 | describe('deleteEntity(schema, id)', function () { 152 | it('should return an action object of type DELETE_ENTITY', function () { 153 | const { Cart } = schemas; 154 | const result = deleteEntity(Cart, 1231); 155 | expect(result.type).to.equal(DELETE_ENTITY); 156 | }); 157 | it('should include a meta property with isEntityAction set to true', function () { 158 | const { Cart } = schemas; 159 | const result = deleteEntity(Cart, 1231); 160 | expect(result.meta.isEntityAction).to.be.true; 161 | }); 162 | it('should include the key of the entity in the payload', function () { 163 | const { Cart } = schemas; 164 | const result = deleteEntity(Cart, 1); 165 | expect(result.payload.key).to.equal('Cart'); 166 | }); 167 | it('should include the id of the entity in the payload', function () { 168 | const { Cart } = schemas; 169 | const id = 1231; 170 | const result = deleteEntity(Cart, id); 171 | expect(result.payload.id).to.equal(id); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/unit/helpers.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { defineSchema, generateSchemas } from 'schema'; 4 | import { 5 | createEntities, 6 | updateEntities, 7 | updateEntityId, 8 | deleteEntities, 9 | } from 'helpers'; 10 | 11 | 12 | describe('@Helpers', function () { 13 | describe('createEntities(schema, dataPath, action)', function () { 14 | let schemas; 15 | before(function () { 16 | const user = defineSchema('User'); 17 | schemas = generateSchemas([user]); 18 | }); 19 | it('should throw an error when `schema` is not a valid schema', function () { 20 | expect(() => createEntities()).to.throw(/INVALID SCHEMA/); 21 | }); 22 | it('should throw an error when `dataPath` is empty', function () { 23 | const { User } = schemas; 24 | expect(() => createEntities(User)).to.throw(/INVALID DATA PATH/); 25 | }); 26 | it('should throw an error when invalid `action`', function () { 27 | const { User } = schemas; 28 | expect(() => createEntities(User, 'payload.data')).to.throw(/INVALID ACTION/); 29 | expect(() => createEntities(User, 'payload.data', { asd: 'asdfa' })).to.throw(/INVALID ACTION/); 30 | }); 31 | it('should return a valid action object', function () { 32 | const { User } = schemas; 33 | const type = 'TEST_ACTION'; 34 | const createUsers = (data) => createEntities(User, 'payload.data', { 35 | type, 36 | payload: { data }, 37 | }); 38 | const result = createUsers([{ name: 'Lars' }]); 39 | expect(result).to.contain.key('type'); 40 | }); 41 | it('the type of the resulting action should be the specified in the wrapped action', function () { 42 | const { User } = schemas; 43 | const type = 'TEST_ACTION'; 44 | const createUsers = (data) => createEntities(User, 'payload.data', { 45 | type, 46 | payload: { data }, 47 | }); 48 | const result = createUsers([{ name: 'Lars' }]); 49 | expect(result.type).to.equal(type); 50 | }); 51 | it('should add a meta property called `isEntmanAction` with the value `true` to the wrapped action', function () { 52 | const { User } = schemas; 53 | const type = 'TEST_ACTION'; 54 | const createUsers = (data) => createEntities(User, 'payload.data', { 55 | type, 56 | payload: { data }, 57 | }); 58 | const result = createUsers([{ name: 'Lars' }]); 59 | expect(result.meta).to.contain.key('isEntmanAction'); 60 | expect(result.meta.isEntmanAction).to.be.true; 61 | }); 62 | it('should add a meta property called `type` with the value `CREATE_ENTITIES` to the wrapped action', function () { 63 | const { User } = schemas; 64 | const type = 'TEST_ACTION'; 65 | const createUsers = (data) => createEntities(User, 'payload.data', { 66 | type, 67 | payload: { data }, 68 | }); 69 | const result = createUsers([{ name: 'Lars' }]); 70 | expect(result.meta).to.contain.key('type'); 71 | expect(result.meta.type).to.equal('CREATE_ENTITIES'); 72 | }); 73 | it('should add a meta property called `dataPath` with the right value to the wrapped action', function () { 74 | const { User } = schemas; 75 | const type = 'TEST_ACTION'; 76 | const createUsers = (data) => createEntities(User, 'payload.data', { 77 | type, 78 | payload: { data }, 79 | }); 80 | const result = createUsers([{ name: 'Lars' }]); 81 | expect(result.meta).to.contain.key('dataPath'); 82 | expect(result.meta.dataPath).to.equal('payload.data'); 83 | }); 84 | it('should add a meta property called `schema` with the schema to the wrapped action', function () { 85 | const { User } = schemas; 86 | const type = 'TEST_ACTION'; 87 | const createUsers = (data) => createEntities(User, 'payload.data', { 88 | type, 89 | payload: { data }, 90 | }); 91 | const result = createUsers([{ name: 'Lars' }]); 92 | expect(result.meta).to.contain.key('schema'); 93 | expect(result.meta.schema.key).to.equal('User'); 94 | }); 95 | }); 96 | describe('updateEntities(schema, ids, dataPath, action)', function () { 97 | let schemas; 98 | let type; 99 | let action; 100 | before(function () { 101 | const user = defineSchema('User'); 102 | schemas = generateSchemas([user]); 103 | const { User } = schemas; 104 | type = 'TEST_TYPE'; 105 | const updateUsers = (ids, data) => updateEntities(User, ids, 'payload.data', { 106 | type, 107 | payload: { data } 108 | }); 109 | action = updateUsers([ 1 ], [{ name: 'Lars' }]); 110 | }); 111 | it('should throw an error when `schema` is not a valid schema', function () { 112 | expect(() => updateEntities()).to.throw(/INVALID SCHEMA/); 113 | }); 114 | it('should throw an error when `ids` is empty', function () { 115 | const { User } = schemas; 116 | expect(() => updateEntities(User)).to.throw(/INVALID IDS/); 117 | }); 118 | it('should throw an error when `dataPath` is empty', function () { 119 | const { User } = schemas; 120 | expect(() => updateEntities(User, [ 1 ])).to.throw(/INVALID DATA PATH/); 121 | }); 122 | it('should throw an error when invalid `action`', function () { 123 | const { User } = schemas; 124 | expect(() => updateEntities(User, [ 1 ], 'payload.data')).to.throw(/INVALID ACTION/); 125 | expect(() => updateEntities(User, [ 1 ], 'payload.data', { asd: 'asdfa' })).to.throw(/INVALID ACTION/); 126 | }); 127 | it('should return a valid action object', function () { 128 | expect(action).to.contain.key('type'); 129 | }); 130 | it('the type of the resulting action should be the specified in the wrapped action', function () { 131 | expect(action.type).to.equal(type); 132 | }); 133 | it('should add a meta property called `isEntmanAction` with the value `true` to the wrapped action', function () { 134 | expect(action.meta).to.contain.key('isEntmanAction'); 135 | expect(action.meta.isEntmanAction).to.be.true; 136 | }); 137 | it('should add a meta property called `type` with the value `UPDATE_ENTITIES` to the wrapped action', function () { 138 | expect(action.meta).to.contain.key('type'); 139 | expect(action.meta.type).to.equal('UPDATE_ENTITIES'); 140 | }); 141 | it('should add a meta property called `dataPath` with the right value to the wrapped action', function () { 142 | expect(action.meta).to.contain.key('dataPath'); 143 | expect(action.meta.dataPath).to.equal('payload.data'); 144 | }); 145 | it('should add a meta property called `schema` with the schema to the wrapped action', function () { 146 | expect(action.meta).to.contain.key('schema'); 147 | expect(action.meta.schema.key).to.equal('User'); 148 | }); 149 | it('should add a meta property called `ids` with the ids to the wrapped action', function () { 150 | expect(action.meta).to.contain.key('ids'); 151 | expect(action.meta.ids).to.deep.equal([ 1 ]); 152 | }); 153 | }); 154 | describe('updateEntityId(schema, oldId, newId, action)', function () { 155 | let schemas; 156 | let action; 157 | let type; 158 | before(function () { 159 | const user = defineSchema('User'); 160 | schemas = generateSchemas([user]); 161 | const { User } = schemas; 162 | type = 'TEST_ACTION'; 163 | const updateUserId = (oldId, newId) => updateEntityId(User, oldId, newId, { type }); 164 | action = updateUserId(1, 2); 165 | }); 166 | it('should throw an error when `schema` is not a valid schema', function () { 167 | expect(() => updateEntityId()).to.throw(/INVALID SCHEMA/); 168 | }); 169 | it('should throw an error when `oldId` is empty', function () { 170 | const { User } = schemas; 171 | expect(() => updateEntityId(User)).to.throw(/INVALID OLD ID/); 172 | }); 173 | it('should throw an error when `newId` is empty', function () { 174 | const { User } = schemas; 175 | expect(() => updateEntityId(User, 1)).to.throw(/INVALID NEW ID/); 176 | }); 177 | it('should throw an error when invalid `action`', function () { 178 | const { User } = schemas; 179 | expect(() => updateEntityId(User, 1, 2)).to.throw(/INVALID ACTION/); 180 | expect(() => updateEntityId(User, 1, 2, { asd: 'asdfa' })).to.throw(/INVALID ACTION/); 181 | }); 182 | it('should return a valid action object', function () { 183 | expect(action).to.contain.key('type'); 184 | }); 185 | it('the type of the resulting action should be the specified in the wrapped action', function () { 186 | expect(action.type).to.equal(type); 187 | }); 188 | it('should add a meta property called `isEntmanAction` with the value `true` to the wrapped action', function () { 189 | expect(action.meta).to.contain.key('isEntmanAction'); 190 | expect(action.meta.isEntmanAction).to.be.true; 191 | }); 192 | it('should add a meta property called `type` with the value `UPDATE_ENTITY_ID` to the wrapped action', function () { 193 | expect(action.meta).to.contain.key('type'); 194 | expect(action.meta.type).to.equal('UPDATE_ENTITY_ID'); 195 | }); 196 | it('should add a meta property called `schema` with the schema to the wrapped action', function () { 197 | expect(action.meta).to.contain.key('schema'); 198 | expect(action.meta.schema.key).to.equal('User'); 199 | }); 200 | it('should add a meta property called `oldId` with the oldId to the wrapped action', function () { 201 | expect(action.meta).to.contain.key('oldId'); 202 | expect(action.meta.oldId).to.equal(1); 203 | }); 204 | it('should add a meta property called `newId` with the newId to the wrapped action', function () { 205 | expect(action.meta).to.contain.key('newId'); 206 | expect(action.meta.newId).to.equal(2); 207 | }); 208 | }); 209 | describe('deleteEntities(schema, id, action)', function () { 210 | let schemas; 211 | let action; 212 | let type; 213 | before(function () { 214 | const user = defineSchema('User'); 215 | schemas = generateSchemas([user]); 216 | const { User } = schemas; 217 | type = 'TEST_ACTION'; 218 | const deleteUser = (ids) => deleteEntities(User, ids, { type }); 219 | action = deleteUser([ 1 ]); 220 | }); 221 | it('should return an error when `schema` is not a valid schema', function () { 222 | expect(() => deleteEntities()).to.throw(/INVALID SCHEMA/); 223 | }); 224 | it('should return an error when `id` is empty', function () { 225 | const { User } = schemas; 226 | expect(() => deleteEntities(User)).to.throw(/INVALID ID/); 227 | }); 228 | it('should throw an error when invalid `action`', function () { 229 | const { User } = schemas; 230 | expect(() => deleteEntities(User, 1)).to.throw(/INVALID ACTION/); 231 | expect(() => deleteEntities(User, 1, { asd: 'asdfa' })).to.throw(/INVALID ACTION/); 232 | }); 233 | it('should return a valid action object', function () { 234 | expect(action).to.contain.key('type'); 235 | }); 236 | it('the type of the resulting action should be the specified in the wrapped action', function () { 237 | expect(action.type).to.equal(type); 238 | }); 239 | it('should add a meta property called `isEntmanAction` with the value `true` to the wrapped action', function () { 240 | expect(action.meta).to.contain.key('isEntmanAction'); 241 | expect(action.meta.isEntmanAction).to.be.true; 242 | }); 243 | it('should add a meta property called `type` with the value `DELETE_ENTITIES` to the wrapped action', function () { 244 | expect(action.meta).to.contain.key('type'); 245 | expect(action.meta.type).to.equal('DELETE_ENTITIES'); 246 | }); 247 | it('should add a meta property called `schema` with the schema to the wrapped action', function () { 248 | expect(action.meta).to.contain.key('schema'); 249 | expect(action.meta.schema.key).to.equal('User'); 250 | }); 251 | it('should add a meta property called `ids` with the ids to the wrapped action', function () { 252 | expect(action.meta).to.contain.key('ids'); 253 | expect(action.meta.ids).to.deep.equal([ 1 ]); 254 | }); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | describe('UNIT TESTS', function () { 2 | require('./utils'); 3 | require('./schema'); 4 | require('./helpers'); 5 | require('./reducer'); 6 | require('./selectors'); 7 | }); 8 | -------------------------------------------------------------------------------- /test/unit/reducer.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import deepFreeze from 'deep-freeze'; 3 | 4 | import { 5 | defineSchema, 6 | generateSchemas, 7 | hasMany, 8 | } from 'schema'; 9 | import entities from 'reducer'; 10 | 11 | 12 | describe('@Reducer', function () { 13 | let schemas; 14 | before(function () { 15 | const group = defineSchema('Group', { 16 | attributes: { 17 | users: hasMany('User'), 18 | } 19 | }); 20 | const user = defineSchema('User', { 21 | attributes: { 22 | group: 'Group', 23 | tasks: hasMany('Task'), 24 | } 25 | }); 26 | const task = defineSchema('Task', { 27 | attributes: { 28 | users: hasMany('User'), 29 | } 30 | }); 31 | schemas = generateSchemas([group, user, task]); 32 | }); 33 | describe('entities(schemas, initialState)', function () { 34 | it('should throw an error when `schemas` is empty', function () { 35 | expect(() => entities()).to.throw('INVALID SCHEMAS'); 36 | }); 37 | it('should return a reducer function', function () { 38 | const result = entities(schemas); 39 | expect(result).to.be.a('function'); 40 | }); 41 | }); 42 | describe('reducer(state, action)', function () { 43 | let reducer; 44 | before(function () { 45 | reducer = entities(schemas); 46 | }); 47 | it('should return the initialState when received undefined as `state`', function() { 48 | const expected = { 49 | Group: {}, 50 | User: {}, 51 | Task: {}, 52 | }; 53 | const result = reducer(undefined, {}); 54 | expect(result).to.deep.equal(expected); 55 | }); 56 | it('should return the passed initialState when specified', function () { 57 | const expected = { 58 | Group: { 1: { id: 1 } }, 59 | User: {}, 60 | Task: {}, 61 | }; 62 | const reducer = entities(schemas, { 63 | Group: { 1: { id: 1 } } 64 | }); 65 | const result = reducer(undefined, {}); 66 | expect(result).to.deep.equal(expected); 67 | }); 68 | describe('when `CREATE_ENTITY_{ENTITY_NAME}` is received as an action', function () { 69 | let finalState; 70 | const group = { name: 'Group 1', id: 1 }; 71 | const user = { name: 'Lars', group: 1, id: 1 }; 72 | const task = { title: 'Do something', user: 1, id: 1 }; 73 | before(function () { 74 | const initialState = deepFreeze(reducer(undefined, {})); 75 | const createGroup = { 76 | type: '@@entman/CREATE_ENTITY_GROUP', 77 | payload: { 78 | entity: group, 79 | key: 'Group', 80 | }, 81 | }; 82 | const createUser = { 83 | type: '@@entman/CREATE_ENTITY_USER', 84 | payload: { 85 | entity: user, 86 | key: 'User', 87 | }, 88 | }; 89 | const createTask = { 90 | type: '@@entman/CREATE_ENTITY_TASK', 91 | payload: { 92 | entity: task, 93 | key: 'Task', 94 | }, 95 | }; 96 | finalState = deepFreeze(reducer(initialState, createGroup)); 97 | finalState = deepFreeze(reducer(finalState, createUser)); 98 | finalState = deepFreeze(reducer(finalState, createTask)); 99 | }); 100 | it('should add the new entity to the state', function () { 101 | expect(finalState.Group[1]).to.exist; 102 | expect(finalState.User[1]).to.exist; 103 | expect(finalState.Task[1]).to.exist; 104 | }); 105 | }); 106 | describe('when `UPDATE_ENTITY_{ENTITY_NAME}` is received as an action', function () { 107 | let finalState; 108 | before(function () { 109 | const initialState = deepFreeze({ 110 | Group: { 111 | 1: { id: 1, name: 'Group 1', users: [ 1 ] }, 112 | 2: { id: 2, name: 'Group 2', users: [ 2 ] }, 113 | }, 114 | User: { 115 | 1: { id: 1, name: 'Lars', group: 1, tasks: [ 1 ] }, 116 | 2: { id: 2, name: 'Deathvoid', group: 2, tasks: [] }, 117 | 3: { id: 2, name: 'Grishan', username: undefined, group: null, tasks: [] }, 118 | }, 119 | Task: { 120 | 1: { id: 1, name: 'Task 1', users: [ 1 ] }, 121 | }, 122 | }); 123 | const updateGroup = { 124 | type: '@@entman/UPDATE_ENTITY_GROUP', 125 | payload: { 126 | entity: { id: 1, name: 'New Group 1' }, 127 | oldEntity: initialState.Group[1], 128 | key: 'Group', 129 | }, 130 | }; 131 | const updateGroup2 = { 132 | type: '@@entman/UPDATE_ENTITY_GROUP', 133 | payload: { 134 | entity: { id: 2, name: undefined }, 135 | oldEntity: initialState.Group[2], 136 | key: 'Group', 137 | }, 138 | }; 139 | const updateUser = { 140 | type: '@@entman/UPDATE_ENTITY_USER', 141 | payload: { 142 | entity: { id: 1, group: 2 }, 143 | oldEntity: initialState.User[1], 144 | key: 'User', 145 | }, 146 | }; 147 | const updateTask = { 148 | type: '@@entman/UPDATE_ENTITY_TASK', 149 | payload: { 150 | entity: { id: 1, users: [ 1, 2 ] }, 151 | oldEntity: initialState.Task[1], 152 | key: 'Task', 153 | }, 154 | }; 155 | finalState = deepFreeze(reducer(initialState, updateGroup)); 156 | finalState = deepFreeze(reducer(finalState, updateGroup2)); 157 | finalState = deepFreeze(reducer(finalState, updateUser)); 158 | finalState = deepFreeze(reducer(finalState, updateTask)); 159 | }); 160 | it('should update single properties of the entity correctly', function () { 161 | expect(finalState.Group[1].name).to.equal('New Group 1'); 162 | }); 163 | it('should not modify properties with a value of undefined', function () { 164 | expect(finalState.Group[2].name).to.equal('Group 2'); 165 | }); 166 | it('should update oneToMany relations correctly', function () { 167 | expect(finalState.User[1].group).to.equal(2); 168 | expect(finalState.Group[2].users).to.include(1); 169 | }); 170 | it('should update manyToMany relations correctly', function () { 171 | expect(finalState.User[2].tasks).to.include(1); 172 | expect(finalState.Task[1].users).to.include(2); 173 | }); 174 | describe('if `defaultTo` in the payload is set to true', function () { 175 | before(function () { 176 | const updateUser3 = { 177 | type: '@@entman/UPDATE_ENTITY_USER', 178 | payload: { 179 | entity: { id: 3, name: 'Grishan2', username: 'grishan' }, 180 | oldEntity: finalState.User[3], 181 | key: 'User', 182 | useDefault: true, 183 | }, 184 | }; 185 | finalState = deepFreeze(reducer(finalState, updateUser3)); 186 | }); 187 | it('should only update undefined properties and not override existing values', function () { 188 | expect(finalState.User[3].name).to.equal('Grishan'); 189 | expect(finalState.User[3].username).to.equal('grishan'); 190 | }); 191 | }); 192 | }); 193 | describe('when `UPDATE_ENTITY_ID` is received as action', function () { 194 | let finalState; 195 | before(function () { 196 | const initialState = deepFreeze({ 197 | Group: { 198 | 1: { id: 1, name: 'Group 1', users: [ 1 ] }, 199 | 2: { id: 2, name: 'Group 2', users: [ 2, 3 ] }, 200 | }, 201 | User: { 202 | 1: { id: 1, name: 'Lars', group: 1, tasks: [ 1 ] }, 203 | 2: { id: 2, name: 'Deathvoid', group: 2, tasks: [] }, 204 | 3: { id: 2, name: 'Grishan', group: 2 }, 205 | }, 206 | Task: { 207 | 1: { id: 1, name: 'Task 1', users: [ 1 ] }, 208 | }, 209 | }); 210 | const updateGroupId = { 211 | type: '@@entman/UPDATE_ENTITY_ID_GROUP', 212 | payload: { 213 | oldId: 1, 214 | newId: 123, 215 | oldEntity: initialState.Group[1], 216 | key: 'Group', 217 | }, 218 | }; 219 | const updateUserId = { 220 | type: '@@entman/UPDATE_ENTITY_ID_USER', 221 | payload: { 222 | oldId: 1, 223 | newId: 123, 224 | oldEntity: initialState.User[1], 225 | key: 'User', 226 | }, 227 | }; 228 | const updateTaskId = { 229 | type: '@@entman/UPDATE_ENTITY_ID_TASK', 230 | payload: { 231 | oldId: 1, 232 | newId: 123, 233 | oldEntity: initialState.Task[1], 234 | key: 'Task', 235 | }, 236 | }; 237 | finalState = deepFreeze(reducer(initialState, updateGroupId)); 238 | finalState = deepFreeze(reducer(finalState, updateUserId)); 239 | finalState = deepFreeze(reducer(finalState, updateTaskId)); 240 | }); 241 | it('should update the id of the entity', function () { 242 | expect(finalState.Group[1]).to.not.exist; 243 | expect(finalState.Group[123]).to.exist; 244 | expect(finalState.User[1]).to.not.exist; 245 | expect(finalState.User[123]).to.exist; 246 | expect(finalState.Task[1]).to.not.exist; 247 | expect(finalState.Task[123]).to.exist; 248 | }); 249 | it('should update oneToMany relations', function () { 250 | expect(finalState.User[123].group).to.equal(123); 251 | expect(finalState.Group[123].users).to.contain(123); 252 | }); 253 | it('should update manyToMany relations', function () { 254 | expect(finalState.User[123].tasks).to.contain(123); 255 | expect(finalState.Task[123].users).to.contain(123); 256 | }); 257 | it('if the attribute specified by the schema is not found on the entity, create it', function () { 258 | expect(finalState.User[3].tasks).to.exist; 259 | expect(finalState.User[3].tasks).to.be.instanceof(Array); 260 | }); 261 | }); 262 | describe('when `DELETE_ENTITY` is received as action', function () { 263 | let finalState; 264 | beforeEach(function () { 265 | const initialState = deepFreeze({ 266 | Group: { 267 | 1: { name: 'Group 1', id: 1, users: [ 1 ] }, 268 | 2: { name: 'Group 2', id: 2, users: [ 2 ] }, 269 | }, 270 | User: { 271 | 1: { name: 'Lars', group: 1, id: 1, tasks: [ 1 ] }, 272 | 2: { name: 'Deathvoid', group: 2, id: 2, tasks: [ 2 ] }, 273 | }, 274 | Task: { 275 | 1: { title: 'Do something', users: [ 1 ], id: 1 }, 276 | 2: { title: 'Do something again', users: [ 2 ], id: 2 }, 277 | }, 278 | }); 279 | const deleteGroup = { 280 | type: '@@entman/DELETE_ENTITY_GROUP', 281 | payload: { 282 | entity: initialState.Group[2], 283 | key: 'Group', 284 | }, 285 | }; 286 | const deleteUser = { 287 | type: '@@entman/DELETE_ENTITY_USER', 288 | payload: { 289 | entity: initialState.User[1], 290 | key: 'User', 291 | }, 292 | }; 293 | const deleteTask = { 294 | type: '@@entman/DELETE_ENTITY_TASK', 295 | payload: { 296 | entity: initialState.Task[2], 297 | key: 'Task', 298 | }, 299 | }; 300 | finalState = deepFreeze(reducer(initialState, deleteGroup)); 301 | finalState = deepFreeze(reducer(finalState, deleteUser)); 302 | finalState = deepFreeze(reducer(finalState, deleteTask)); 303 | }); 304 | it('should delete the entity', function () { 305 | expect(finalState.Group[2]).to.not.exist; 306 | expect(finalState.User[1]).to.not.exist; 307 | expect(finalState.Task[2]).to.not.exist; 308 | }); 309 | it('should update oneToMany relations', function () { 310 | expect(finalState.Group[1].users).to.not.contain(1); 311 | expect(finalState.User[2].group).to.be.null; 312 | }); 313 | it('should update manyToMany relations', function () { 314 | expect(finalState.Task[1].users).to.not.include(1); 315 | expect(finalState.User[2].tasks).to.not.include(2); 316 | }); 317 | }); 318 | }); 319 | }); 320 | -------------------------------------------------------------------------------- /test/unit/schema.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { schema as Schema } from 'normalizr'; 3 | 4 | import { 5 | defineSchema, 6 | hasMany, 7 | generateSchemas, 8 | } from 'schema'; 9 | 10 | 11 | describe('@Schema', function () { 12 | describe('defineSchema(name, config)', function () { 13 | it('should throw an error when `name` is empty', function () { 14 | expect(() => defineSchema()).to.throw(/INVALID NAME/); 15 | }); 16 | it('should throw an error when `config` is not an object', function () { 17 | expect(() => defineSchema('Test', () => {})).to.throw(/INVALID CONFIG/); 18 | expect(() => defineSchema('Test', 123)).to.throw(/INVALID CONFIG/); 19 | }); 20 | it('should return an object', function () { 21 | const result = defineSchema('Test'); 22 | expect(result).to.be.an('object'); 23 | }); 24 | it('the schema object should have a property with the name of the schema', function () { 25 | const name = 'Test'; 26 | const result = defineSchema(name); 27 | expect(result.name).to.equal(name); 28 | }); 29 | it('the schema object should have a property with the attributes of the schema', function () { 30 | const name = 'Test'; 31 | const attributes = { user: 'User' }; 32 | const result = defineSchema(name, { attributes }); 33 | expect(result.config.attributes).to.deep.equal(attributes); 34 | expect(defineSchema(name).config.attributes).to.be.an('object'); 35 | }); 36 | }); 37 | describe('hasMany(schema)', function () { 38 | it('should throw an error when `schema` is empty', function () { 39 | expect(() => hasMany()).to.throw(/INVALID SCHEMA/); 40 | }); 41 | it('should throw an error if `schema` is not a string or a schema object', function () { 42 | expect(() => hasMany(123)).to.throw(/INVALID SCHEMA/); 43 | expect(() => hasMany({})).to.throw(/INVALID SCHEMA/); 44 | expect(() => hasMany('User')).to.not.throw(/INVALID SCHEMA/); 45 | expect(() => hasMany({ name: 'User' })).to.not.throw(/INVALID SCHEMA/); 46 | }); 47 | it('should return an object', function () { 48 | const result = hasMany('User'); 49 | expect(result).to.be.an('object'); 50 | }); 51 | it('the resulted object should have a property called `relatedSchema` with the name of the passed schema', function () { 52 | const name = 'User'; 53 | const result = hasMany(name); 54 | expect(result.relatedSchema).to.equal(name); 55 | }); 56 | it('the resulted object should have a property called `isArray` with value `true`', function () { 57 | const result = hasMany('User'); 58 | expect(result.isArray).to.equal(true); 59 | }); 60 | }); 61 | describe('generateSchemas(schemas)', function () { 62 | it('should throw an error if `schemas` is empty', function () { 63 | expect(() => generateSchemas()).to.throw(/INVALID SCHEMAS/); 64 | }); 65 | it('should throw an error if `schemas` is not an array', function () { 66 | expect(() => generateSchemas({})).to.throw(/INVALID SCHEMAS/); 67 | expect(() => generateSchemas(123)).to.throw(/INVALID SCHEMAS/); 68 | }); 69 | it('should return an object', function () { 70 | const user = defineSchema('User'); 71 | const group = defineSchema('Group'); 72 | const result = generateSchemas([user, group]); 73 | expect(result).to.be.an('object'); 74 | }); 75 | it('the keys of the resulted object should match the schemas names', function () { 76 | const user = defineSchema('User'); 77 | const group = defineSchema('Group'); 78 | const result = generateSchemas([user, group]); 79 | expect(result).to.include.keys('User'); 80 | expect(result).to.include.keys('Group'); 81 | }); 82 | it('the resulted object should contain valid schemas', function () { 83 | const user = defineSchema('User', { 84 | attributes: { 85 | group: 'Group', 86 | } 87 | }); 88 | const group = defineSchema('Group', { 89 | attributes: { 90 | users: hasMany('User'), 91 | getNumberOfUsers() { 92 | return this.users.length; 93 | } 94 | } 95 | }); 96 | const result = generateSchemas([user, group]); 97 | expect(result.Group.key).to.equal('Group'); 98 | expect(result.User.key).to.equal('User'); 99 | expect(result.Group).to.an.instanceof(Schema.Entity); 100 | expect(result.User).to.an.instanceof(Schema.Entity); 101 | }); 102 | it('the resulted schemas should contain the right attributes', function () { 103 | const user = defineSchema('User', { 104 | attributes: { 105 | group: 'Group', 106 | } 107 | }); 108 | const group = defineSchema('Group', { 109 | attributes: { 110 | users: hasMany('User'), 111 | getNumberOfUsers() { 112 | return this.users.length; 113 | } 114 | } 115 | }); 116 | const result = generateSchemas([user, group]); 117 | expect(result.Group.schema).to.contain.keys(['users', '_computed']); 118 | expect(result.Group.schema._computed).to.contain.keys(['getNumberOfUsers']); 119 | expect(result.User.schema).to.contain.keys(['group']); 120 | }); 121 | it('should add a method called `getRelations` to the generated schemas', function () { 122 | const user = defineSchema('User'); 123 | const group = defineSchema('Group'); 124 | const result = generateSchemas([user, group]); 125 | expect(result.User.getRelations).to.exist; 126 | expect(result.Group.getRelations).to.exist; 127 | }); 128 | it('the method `getRelations` should return an array with the info of the relations of the schema', function () { 129 | const user = defineSchema('User', { 130 | attributes: { 131 | group: 'Group', 132 | tasks: hasMany('Task'), 133 | }, 134 | }); 135 | const group = defineSchema('Group', { 136 | attributes: { 137 | users: hasMany('User'), 138 | }, 139 | }); 140 | const task = defineSchema('Task', { 141 | attributes: { 142 | users: hasMany('User'), 143 | }, 144 | }); 145 | const result = generateSchemas([ user, group, task ]); 146 | const userRelations = [ 147 | { 148 | through: 'group', 149 | isMany: false, 150 | foreign: 'users', 151 | to: 'Group', 152 | }, 153 | { 154 | through: 'tasks', 155 | isMany: true, 156 | foreign: 'users', 157 | to: 'Task', 158 | }, 159 | ]; 160 | const groupRelations = [ 161 | { 162 | through: 'users', 163 | isMany: true, 164 | foreign: 'group', 165 | to: 'User', 166 | }, 167 | ]; 168 | const taskRelations = [ 169 | { 170 | through: 'users', 171 | isMany: true, 172 | foreign: 'tasks', 173 | to: 'User', 174 | }, 175 | ]; 176 | expect(result.Group.getRelations()).to.deep.equal(groupRelations); 177 | expect(result.User.getRelations()).to.deep.equal(userRelations); 178 | expect(result.Task.getRelations()).to.deep.equal(taskRelations); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /test/unit/selectors.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | defineSchema, 5 | generateSchemas, 6 | hasMany, 7 | } from 'schema'; 8 | import { 9 | getEntities, 10 | getEntitiesBy, 11 | getEntity, 12 | } from 'selectors'; 13 | 14 | 15 | describe('@Selectors', function () { 16 | let state; 17 | let schemas; 18 | before(function () { 19 | const group = defineSchema('Group', { 20 | attributes: { 21 | users: hasMany('User'), 22 | } 23 | }); 24 | const user = defineSchema('User', { 25 | attributes: { 26 | group: 'Group', 27 | tasks: hasMany('Task'), 28 | } 29 | }); 30 | const task = defineSchema('Task', { 31 | attributes: { 32 | user: 'User', 33 | category: 'Category', 34 | //TODO: users: hasMany('User'), 35 | } 36 | }); 37 | const category = defineSchema('Category', { 38 | }); 39 | schemas = generateSchemas([group, user, task, category]); 40 | state = { entities: { 41 | Group: { 42 | 1: { id: 1, name: 'Group 1', users: [ 1, 2 ] }, 43 | 2: { id: 2, name: 'Group 2', users: [ 3 ] }, 44 | }, 45 | User: { 46 | 1: { id: 1, name: 'Lars', group: 1 }, 47 | 2: { id: 2, name: 'Grishan', group: 1 }, 48 | 3: { id: 3, name: 'Deathvoid', group: 2 }, 49 | }, 50 | Task: { 51 | 1: { id: 1, title: 'Do something', user: 1 }, 52 | 2: { id: 2, title: 'Keep calm', user: 1, category: 1 }, 53 | }, 54 | Category: { 55 | }, 56 | } }; 57 | }); 58 | describe('getEntities(state, schema)', function () { 59 | let entities; 60 | before(function () { 61 | entities = getEntities(state, schemas.Group); 62 | }); 63 | it('should return all the entities of schema = `schema`', function () { 64 | expect(entities).to.have.length(2); 65 | expect(entities).to.satisfy(entities => entities.some(e => e.id === 1)); 66 | expect(entities).to.satisfy(entities => entities.some(e => e.id === 2)); 67 | }); 68 | it('should populate all relationships', function () { 69 | const group1 = entities.find(e => e.id === 1); 70 | expect(group1.users).to.have.length(2); 71 | expect(group1.users).to.satisfy(users => users.some(u => u.id === 1)); 72 | expect(group1.users).to.satisfy(users => users.some(u => u.id === 2)); 73 | }); 74 | }); 75 | describe('getEntitiesBy(state, schema, by={})', function () { 76 | let entities; 77 | before(function () { 78 | entities = getEntitiesBy(state, schemas.Group, { name: 'Group 1' }); 79 | }); 80 | it('should return all the entities of schema = `schema` that fulfil the condition `by`', function () { 81 | expect(entities).to.have.length(1); 82 | expect(entities[0].id).to.equal(1); 83 | }); 84 | it('should return an empty array when no entity fulfil the condition `by`', function () { 85 | const entities = getEntitiesBy(state, schemas.Group, { name: 'asdfa' }); 86 | expect(entities).to.be.an('array'); 87 | expect(entities).to.have.length(0); 88 | }); 89 | it('should populate all relationships', function () { 90 | const group1 = entities.find(e => e.id === 1); 91 | expect(group1.users).to.have.length(2); 92 | expect(group1.users).to.satisfy(users => users.some(u => u.id === 1)); 93 | expect(group1.users).to.satisfy(users => users.some(u => u.id === 2)); 94 | }); 95 | }); 96 | describe('getEntity(state, schema, id)', function () { 97 | let entity; 98 | before(function () { 99 | entity = getEntity(state, schemas.Group, 1); 100 | }); 101 | it('should throw an error if no `id` is specified', function () { 102 | const result = () => getEntity(state, schemas.Group); 103 | expect(result).to.throw(/Required param/); 104 | }); 105 | it('should return the entity of schema = `schema` with the specified `id`', function () { 106 | expect(entity.id).to.equal(1); 107 | }); 108 | it('should populate all relationships', function () { 109 | const group1 = entity; 110 | expect(group1.users).to.have.length(2); 111 | expect(group1.users).to.satisfy(users => users.some(u => u.id === 1)); 112 | expect(group1.users).to.satisfy(users => users.some(u => u.id === 2)); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/unit/utils/index.js: -------------------------------------------------------------------------------- 1 | describe('@Utils', function () { 2 | }); 3 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Drawbotics/entman/19e0f39e3c6dc9cf17e3132f6a161e4cb2d6b0e5/vendor/.gitkeep -------------------------------------------------------------------------------- /webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | 5 | const rootDirs = [ 6 | path.resolve(__dirname, 'src'), 7 | path.resolve(__dirname, 'test'), 8 | ]; 9 | 10 | 11 | module.exports = { 12 | resolve: { 13 | modules: [ 14 | ...rootDirs, 15 | path.resolve(__dirname, 'node_modules'), 16 | ], 17 | extensions: ['.js'], 18 | }, 19 | entry: [ './src/index.js' ], 20 | output: { 21 | path: path.resolve(__dirname, 'dist'), 22 | publicPath: '/', 23 | filename: 'entman.js', 24 | library: 'entman', 25 | libraryTarget: 'umd', 26 | }, 27 | plugins: [ 28 | ], 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.jsx?$/, 33 | enforce: 'pre', 34 | include: rootDirs, 35 | use: [ 36 | { 37 | loader: 'eslint-loader', 38 | }, 39 | ], 40 | }, 41 | { 42 | test: /\.jsx?$/, 43 | include: rootDirs, 44 | use: [ 45 | { 46 | loader: 'babel-loader', 47 | options: { 48 | presets: [ [ 'es2015', { modules: false } ], 'stage-0' ], 49 | }, 50 | }, 51 | ], 52 | } 53 | ], 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | const webpackBaseConfig = require('./webpack.base.config.js'); 4 | 5 | 6 | module.exports = Object.assign({}, webpackBaseConfig, { 7 | devtool: 'cheap-module-source-map', 8 | plugins: [ 9 | ...webpackBaseConfig.plugins, 10 | new webpack.DefinePlugin({ 11 | 'process.env': { NODE_ENV: JSON.stringify('production') } 12 | }), 13 | new webpack.optimize.UglifyJsPlugin({ 14 | drop_console: true 15 | }), 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /webpack.test.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const webpackBaseConfig = require('./webpack.base.config.js'); 4 | 5 | 6 | module.exports = Object.assign({}, webpackBaseConfig, { 7 | devtool: 'inline-source-map', 8 | plugins: [ 9 | ...webpackBaseConfig.plugins, 10 | ], 11 | }); 12 | --------------------------------------------------------------------------------