├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── blog │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── actions │ │ │ └── index.js │ │ ├── containers │ │ │ ├── ArticleList.js │ │ │ └── ArticlePage.js │ │ ├── db.json │ │ ├── index.js │ │ ├── reducers │ │ │ └── index.js │ │ └── selectors │ │ │ └── index.js │ └── yarn.lock └── todos │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ └── index.html │ ├── src │ ├── actions │ │ └── index.js │ ├── components │ │ ├── App.js │ │ ├── Footer.js │ │ ├── Link.js │ │ ├── Todo.js │ │ └── TodoList.js │ ├── containers │ │ ├── AddTodo.js │ │ ├── FilterLink.js │ │ └── VisibleTodoList.js │ ├── index.js │ ├── reducers │ │ ├── index.js │ │ └── visibilityFilter.js │ └── selectors │ │ └── index.js │ └── yarn.lock ├── package.json ├── src ├── __mocks__ │ └── index.js ├── __tests__ │ ├── getActionCreators.spec.jest.js │ ├── getSelectors.spec.jest.js │ ├── itemsByListNameSelectorFactory.js │ ├── listSelectorFactory.spec.jest.js │ ├── mapSelector.spec.jest.js │ ├── reducer.spec.jest.js │ └── selectorsFactory.spec.jest.js ├── actionTypeHelpers.js ├── getActionCreators.js ├── getSelectors.js ├── index.js ├── reducer.js └── selectorsFactory.js ├── webpack.config.babel.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["latest"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | **/dist/** 3 | **/es/** 4 | **/lib/** 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "standard-react"], 3 | "env": { 4 | "browser": true 5 | }, 6 | "parserOptions": { 7 | "ecmaVersion": 8 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.log 3 | node_modules 4 | dist 5 | lib 6 | es 7 | coverage 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mirakl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # STATUS : DEPRECATED AND UNMAINTAINED 2 | 3 | We recommend you to use another lib like [react-query](https://github.com/tannerlinsley/react-query), which also do client-side 4 | caching, without using redux, in a better way. 5 | 6 | 7 | # 8 | 9 | Redux lists middleware is a tool to manage the models (like User, Article, Product for instance) in your application in an optimized and simple way. 10 | 11 | ## Installation 12 | 13 | `npm install --save redux-lists` 14 | 15 | The redux store should know how to handle actions coming from the redux-lists action creators. To enable this, we need to pass the redux-lists `reducer` to your store: 16 | 17 | ```javascript 18 | import { createStore, combineReducers } from 'redux'; 19 | import { reducer as reduxListsReducer } from 'redux-lists'; 20 | 21 | const rootReducer = combineReducers({ 22 | // ...your other reducers here 23 | reduxLists: reduxListsReducer 24 | }); 25 | 26 | const store = createStore(rootReducer); 27 | ``` 28 | 29 | ## Table of contents 30 | 31 | * [Motivations](#motivations) 32 | * [Concept in draw](#concept-in-draw) 33 | * [Case - a blog app](#case---a-blog-app) 34 | + [Redux-lists in action](#redux-lists-in-action) 35 | + [Explanation](#explanation) 36 | - [Files description](#files-description) 37 | - [articleActions and articleSelectors](#articleactions-and-articleselectors) 38 | - [ArticleList workflow](#articlelist-workflow) 39 | - [ArticlePage workflow](#articlepage-workflow) 40 | + [Redux state-tree sample](#redux-state-tree-sample) 41 | - [Redux-lists state-tree evolution](#redux-lists-state-tree-evolution) 42 | * [API](#api) 43 | + [`getActionCreators(namespace, options)`](#getactioncreatorsnamespace-options) 44 | + [`getSelectors(namespace)`](#getselectorsnamespace) 45 | 46 | 47 | ## Motivations 48 | 49 | Redux-lists is useful to: 50 | 51 | - Factorize in a single place your models objects 52 | - Do optimistic updates and improve your app responsiveness very easily 53 | - Reduce the redux boilerplate: generates action creators + selectors to manage your model collections 54 | - Improve code consistency 55 | 56 | ## Concept in draw 57 | 58 | ![image](https://user-images.githubusercontent.com/32459740/39523774-0a7c01ce-4e17-11e8-8054-ca2e8ea465d1.png) 59 | ![image](https://user-images.githubusercontent.com/32459740/39522704-571ae6a2-4e13-11e8-901a-797f99ae2728.png) 60 | ![image](https://user-images.githubusercontent.com/32459740/39522713-62250c94-4e13-11e8-9776-c4ffa8a9efbe.png) 61 | 62 | ## Case - a blog app 63 | 64 | In a blogging application, we probably will have... Articles ! Using redux, we will want to do the following operations in our app: 65 | 66 | - Render a list of blog articles, with only their title and eventually a quick description 67 | - Render a single blog article, with all it's information 68 | 69 | If you are used to redux, you probably already are thinking about how you are going to store those objects in the state-tree, the action creators and the selectors that you will have to make to get that data from the tree. 70 | 71 | ### Redux-lists in action 72 | 73 | That's where redux-lists is useful, because it provides you those tools and even more! 74 | 75 | For instance, in this case we want to manage a collection of articles. Redux-lists gives you action creators and selectors to manage those articles in your application. 76 | 77 | Firstly, we need to generate those tools by giving redux-list the namespace you want them to operate in: 78 | 79 | *articleActions.js* 80 | ```javascript 81 | import { getActionCreators } from 'redux-lists'; 82 | 83 | export const { setList: setArticleList, updateItems: updateArticles } = getActionCreators('ARTICLES'); 84 | ``` 85 | 86 | *articleSelectors.js* 87 | ```javascript 88 | import { getSelectors } from 'redux-lists'; 89 | 90 | export const { listSelector: articlesListSelector, byKeySelector: articleByIdSelector } = getSelectors('ARTICLES'); 91 | ``` 92 | 93 | Now, we want to write our ArticleList component. Here is the mechanism that we want to build here: 94 | 95 | ![image](https://user-images.githubusercontent.com/32459740/39526911-7304083c-4e20-11e8-95c3-4c291bb33496.png) 96 | ![image](https://user-images.githubusercontent.com/32459740/39525833-6e292f70-4e1d-11e8-9e51-43a8914650c3.png) 97 | 98 | *ArticleList.js* 99 | ```javascript 100 | import React from 'react'; 101 | import { bindActionCreators } from 'redux'; 102 | import { connect } from 'react-redux'; 103 | 104 | import { setArticleList } from './articleActions.js'; 105 | import { articlesListSelector } from './articleSelectors.js'; 106 | 107 | class ArticleList extends React.Component { 108 | componentDidMount() { 109 | fetch('/articles') 110 | .then(response => response.json()) 111 | .then(articles => { 112 | this.props.setArticleList(articles, 'ALL'); 113 | }); 114 | } 115 | 116 | render() { 117 | const { articles } = this.props; 118 | if (!articles) return 'Loading'; 119 | if (articles.length === 0) return 'No articles yet !'; 120 | 121 | return ( 122 | 130 | ) 131 | } 132 | } 133 | 134 | function mapStateToProps(state) { 135 | return { 136 | articles: articlesListSelector(state, 'ALL') 137 | } 138 | } 139 | 140 | function mapDispatchToProps(dispatch) { 141 | return { 142 | setArticleList: bindActionCreators(setArticleList, dispatch) 143 | } 144 | } 145 | 146 | export default connect(mapStateToProps, mapDispatchToProps)(ArticleList); 147 | ``` 148 | 149 | Now let's consider we clicked on an article item in the list because we want to access this article page. 150 | 151 | Here is a possible workflow for the ArticlePage component: 152 | 153 | ![image](https://user-images.githubusercontent.com/32459740/39525972-c82e8894-4e1d-11e8-96c7-68e6321c534b.png) 154 | 155 | > **Note :** We just performed an optimistic update ! Keep in mind that this behaviour is not mandatory to use with redux-list, but if you want to use such a mechanism redux-list makes it easy for you. 156 | 157 | ![image](https://user-images.githubusercontent.com/32459740/39527072-d67dfd28-4e20-11e8-9fa2-8692f1605bf0.png) 158 | ![image](https://user-images.githubusercontent.com/32459740/39526008-ddff05cc-4e1d-11e8-8ac4-e39ea9c85fda.png) 159 | 160 | *ArticlePage.js* 161 | ```javascript 162 | import React from 'react'; 163 | import { bindActionCreators } from 'redux'; 164 | import { connect } from 'react-redux'; 165 | 166 | import { updateArticles } from './articleActions.js'; 167 | import { articleByIdSelector } from './articleSelectors.js'; 168 | 169 | class ArticlePage extends React.Component { 170 | componentDidMount() { 171 | fetch(`/articles/${this.props.id}`) 172 | .then(response => response.json()) 173 | .then(article => { 174 | this.props.updateArticles(article); 175 | }); 176 | } 177 | 178 | render() { 179 | const { article } = this.props; 180 | if (!article) return 'Loading'; 181 | 182 | return ( 183 |
184 |

{article.label}

185 |

{article.author}

186 |

{article.createdAt}

187 | 188 |

{article.content}

189 |
190 | ) 191 | } 192 | } 193 | 194 | function mapStateToProps(state, ownProps) { 195 | const articleId = ownProps.id; 196 | return { 197 | article: articleByIdSelector(state, articleId) 198 | } 199 | } 200 | 201 | function mapDispatchToProps(dispatch) { 202 | return { 203 | updateArticles: bindActionCreators(updateArticles, dispatch) 204 | } 205 | } 206 | 207 | export default connect(mapStateToProps, mapDispatchToProps)(ArticlePage); 208 | ``` 209 | 210 | ### Detailed explanations 211 | 212 | Let's take some time here to process and understand what is happening here. 213 | 214 | #### Files description 215 | 216 | - **articleActions.js** 217 | 218 | If you are familiar to redux, you know what this is. If you don't, you should learn how [redux](https://redux.js.org/) works first. 219 | 220 | - **articleSelectors.js** 221 | 222 | Contains functions (*memoized*) that go through the redux state-tree to get information in it. 223 | 224 | - **ArticleList.js** 225 | 226 | A react component that fetches and renders a list of articles. 227 | 228 | - **ArticlePage.js** 229 | 230 | A react component that fetches and renders a blog article page. 231 | 232 | #### articleActions and articleSelectors 233 | 234 | In `articleActions`, we create the `setArticleList` and `updateArticles` *redux-lists* actions. 235 | 236 | `getActionCreators` takes two parameters, the first being the **namespace** and the second being an options object. 237 | 238 | Indeed, the model's objects (here the articles) are going to be stored in the state tree `@@redux-lists/namespace` (here `@@redux-lists/ARTICLES`). 239 | 240 | Because of this, we also need to create the redux-lists articleSelectors in giving it the **namespace** they will have to look in: `getSelectors('ARTICLES')`. 241 | 242 | #### ArticleList workflow 243 | 244 | When the `ArticleList` component is mounted, an AJAX request is performed to get the `articles` from the server. 245 | 246 | When this request is fulfilled, we store those articles in a **list** called `'ALL'` in dispatching `setArticleList` action. 247 | 248 | > Note: We will see how this list is structured in the redux state-tree right after, just keep in mind that we store the articles in a named list for now. 249 | 250 | The state-tree gets updated, the `mapStateToProps` function is called. We use the `articlesListSelector` to get the articles we stored in redux. `articlesListSelector` takes the redux `state` and the `listName` we stored the articles in. 251 | 252 | The articles are passed to our `ArticleList` and they are rendered in the UI. 253 | 254 | #### ArticlePage workflow 255 | 256 | Firstly, let's consider that the user has previously rendered the previous `ArticleList` to access to the `ArticlePage`. 257 | 258 | When the `ArticlePage` component is getting mounted, `mapStateToProps` function is called. Since we already have some information about the article in the redux store (label, quick description), the `articleByIdSelector` finds them. 259 | 260 | It means that the ArticlePage can render instantly and prepare the user for the rest of the information to be fetched if you want to. 261 | 262 | When `ArticlePage` is mounted, an AJAX request is performed to get the `article` full information (it's content for instance). 263 | 264 | When this request is fulfilled, we update the article already stored in redux in dispatching the `updateArticles` action. 265 | 266 | The state-tree gets updated, the `mapStateToProps` function is called. Our `articleByIdSelector` gets our article with the new information we just fetched and `ArticlePage` re-renders. 267 | 268 | ### Redux state-tree sample 269 | 270 | To get a better grasp on what's happens with your objects when you set a redux-lists or when you make an update, let's see a sample redux-lists tree that could be produced in an application blog: 271 | 272 | ```json 273 | { 274 | "@@redux-lists": { 275 | "ARTICLES": { 276 | "list": { 277 | "ALL": ["article1", "article2", "article3"], 278 | "NEW": ["article3"], 279 | "AUTHOR=MANU": ["article1", "article2"], 280 | "AUTHOR=TOTO": ["article3"] 281 | }, 282 | "map": { 283 | "article1": { 284 | "id": "article1", 285 | "label": "My first article !", 286 | "description": "This is my first article", 287 | "author": "MANU" 288 | }, 289 | "article2": { 290 | "id": "article2", 291 | "label": "My second article !", 292 | "description": "This is my second article", 293 | "content": "Okay, this is my first article and it's about redux-lists!", 294 | "author": "MANU" 295 | }, 296 | "article3": { 297 | "id": "article3", 298 | "label": "The third article", 299 | "description": "It's the third article of the blog but my first!", 300 | "author": "TOTO" 301 | } 302 | } 303 | } 304 | } 305 | } 306 | ``` 307 | 308 | Redux-lists normalizes your array of objects into a map / list structure. This is pretty convenient because it avoids repetition of information, here the articles objects are in the map and their id used as a reference in the lists. 309 | 310 | #### Redux-lists state-tree evolution 311 | 312 | Let's consider this state-tree: 313 | 314 | ```json 315 | { 316 | "@@redux-lists": { 317 | "ARTICLES": { 318 | "list": {}, 319 | "map": {} 320 | } 321 | } 322 | } 323 | ``` 324 | 325 | And let's consider that `fetch('/articles')` returned those `articles`: 326 | 327 | ```json 328 | [ 329 | { 330 | "id": "article1", 331 | "label": "My first article !", 332 | "description": "This is my first article", 333 | "author": "MANU" 334 | }, 335 | { 336 | "id": "article2", 337 | "label": "My second article !", 338 | "description": "This is my second article", 339 | "author": "MANU" 340 | }, 341 | { 342 | "id": "article3", 343 | "label": "The third article", 344 | "description": "It's the third article of the blog but my first!", 345 | "author": "TOTO" 346 | } 347 | ] 348 | ``` 349 | 350 | When we called `setArticleList` in our `ArticleList` component 351 | 352 | ```javascript 353 | this.props.setArticleList(articles, 'ALL'); 354 | ``` 355 | 356 | here is what happened to redux-lists state tree: 357 | 358 | ```json 359 | { 360 | "@@redux-lists": { 361 | "ARTICLES": { 362 | "list": { 363 | "ALL": ["article1", "article2", "article3"] 364 | }, 365 | "map": { 366 | "article1": { 367 | "id": "article1", 368 | "label": "My first article !", 369 | "description": "This is my first article", 370 | "author": "MANU" 371 | }, 372 | "article2": { 373 | "id": "article2", 374 | "label": "My second article !", 375 | "description": "This is my second article", 376 | "author": "MANU" 377 | }, 378 | "article3": { 379 | "id": "article3", 380 | "label": "The third article", 381 | "description": "It's the third article of the blog but my first!", 382 | "author": "TOTO" 383 | } 384 | } 385 | } 386 | } 387 | } 388 | ``` 389 | 390 | ## API 391 | 392 | ### `getActionCreators(namespace, options)` 393 | 394 | - (*String*) `namespace`: A namespace for the map / lists of your model 395 | - (*Object*) `options`: supported options are: 396 | * (*String*, default = 'id') `onKey`: The key used as reference for objects manipulations 397 | 398 | `getActionCreators` returns an *Object* containing the keys: 399 | 400 | - (*Function*) `setList(items, listName)`: Adds items in the collection + places references in the list 401 | * (*Array of objects*) `items`: Model objects to be placed in the list. Object must at least have an unique identifier (`id` or the one defined by the above `onKey`) 402 | * (*String*) name of the list you want to place your objects in. 403 | 404 | - (*Function*) `updateItems(items)`: Upsert items in the collection 405 | * (*Object* or *Array of objects*) `items`: Places the objects into the redux-lists map 406 | 407 | ### `getSelectors(namespace)` 408 | 409 | - (*String*) `namespace`: A namespace for the map / lists of your model 410 | 411 | `getSelectors` returns an *Object* containing the keys: 412 | 413 | - (*Function*) `listSelector(state, listName)`: Returns an array of objects previously put in the list with this *listName* 414 | * (*Object*) `state`: the entire redux state-tree 415 | * (*String*) `listName`: the list you want to extract the objects from 416 | 417 | - (*Function*) `byKeySelector(state, itemKey)`: Returns the object that have the `itemKey` key value (defined with `getActionCreators`) 418 | * (*Object*) `state`: the entire redux state-tree 419 | * (*String*) `itemKey`: the itemKey value of the object you want to read on the redux-lists store. 420 | 421 | > **Note :** The selectors are *memoized* so that if something has been processed / accessed once, it won't compute it again if it's asked another time with the same parameters. 422 | -------------------------------------------------------------------------------- /examples/blog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "^1.1.4" 7 | }, 8 | "dependencies": { 9 | "concurrently": "^3.5.1", 10 | "json-server": "^0.12.2", 11 | "prop-types": "^15.6.1", 12 | "react": "^16.3.1", 13 | "react-dom": "^16.3.1", 14 | "react-redux": "^5.0.7", 15 | "react-router-dom": "^4.2.2", 16 | "redux": "^3.5.2", 17 | "redux-thunk": "^2.2.0", 18 | "redux-lists": "^1.0.0" 19 | }, 20 | "scripts": { 21 | "start": "concurrently \"json-server --watch src/db.json --port 3004\" \"react-scripts start\"", 22 | "build": "react-scripts build", 23 | "eject": "react-scripts eject", 24 | "test": "react-scripts test" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/blog/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Blog - Redux-lists 7 | 8 | 9 |
10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/blog/src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { getActionCreators } from 'redux-lists' 2 | 3 | export const { 4 | setList: setArticleList, 5 | updateItems: updateArticles 6 | } = getActionCreators('ARTICLES') 7 | -------------------------------------------------------------------------------- /examples/blog/src/containers/ArticleList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { bindActionCreators } from 'redux' 4 | import { connect } from 'react-redux' 5 | import { setArticleList } from '../actions' 6 | import { articlesListSelector } from '../selectors' 7 | import { Link } from 'react-router-dom' 8 | 9 | class ArticleList extends React.Component { 10 | componentDidMount () { 11 | fetch('http://localhost:3004/articles') 12 | .then(response => response.json()) 13 | .then(articles => { 14 | this.props.setArticleList(articles, 'ALL') 15 | }) 16 | } 17 | 18 | render () { 19 | const { articles } = this.props 20 | if (!articles) return 'Loading' 21 | if (articles.length === 0) return 'No articles yet !' 22 | 23 | return ( 24 | 34 | ) 35 | } 36 | } 37 | 38 | ArticleList.propTypes = { 39 | setArticleList: PropTypes.func, 40 | articles: PropTypes.arrayOf( 41 | PropTypes.shape({ 42 | id: PropTypes.string, 43 | title: PropTypes.string, 44 | description: PropTypes.string 45 | }) 46 | ) 47 | } 48 | 49 | function mapStateToProps (state) { 50 | return { 51 | articles: articlesListSelector(state, 'ALL') 52 | } 53 | } 54 | 55 | function mapDispatchToProps (dispatch) { 56 | return { 57 | setArticleList: bindActionCreators(setArticleList, dispatch) 58 | } 59 | } 60 | 61 | export default connect(mapStateToProps, mapDispatchToProps)(ArticleList) 62 | -------------------------------------------------------------------------------- /examples/blog/src/containers/ArticlePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { bindActionCreators } from 'redux' 3 | import { connect } from 'react-redux' 4 | import { updateArticles } from '../actions' 5 | import { articleByIdSelector } from '../selectors' 6 | import PropTypes from 'prop-types' 7 | 8 | class ArticlePage extends React.Component { 9 | componentDidMount () { 10 | fetch(`http://localhost:3004/articles/${this.props.id}`) 11 | .then(response => response.json()) 12 | .then(article => { 13 | this.props.updateArticles(article) 14 | }) 15 | } 16 | 17 | render () { 18 | const { article } = this.props 19 | if (!article) return 'Loading' 20 | 21 | return ( 22 |
23 |

{article.title}

24 |

{article.author}

25 |

{article.createdAt}

26 | 27 |

{article.content}

28 |
29 | ) 30 | } 31 | } 32 | 33 | ArticlePage.propTypes = { 34 | updateArticles: PropTypes.func, 35 | id: PropTypes.string, 36 | article: PropTypes.shape({ 37 | title: PropTypes.string, 38 | author: PropTypes.string, 39 | createdAt: PropTypes.string, 40 | content: PropTypes.string 41 | }) 42 | } 43 | 44 | function mapStateToProps (state, ownProps) { 45 | const articleId = ownProps.match.params.id 46 | return { 47 | article: articleByIdSelector(state, articleId), 48 | id: articleId 49 | } 50 | } 51 | 52 | function mapDispatchToProps (dispatch) { 53 | return { 54 | updateArticles: bindActionCreators(updateArticles, dispatch) 55 | } 56 | } 57 | 58 | export default connect(mapStateToProps, mapDispatchToProps)(ArticlePage) 59 | -------------------------------------------------------------------------------- /examples/blog/src/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "articles": [ 3 | { "id": "1", "title": "My first article", "author": "Manu" }, 4 | { "id": "2", "title": "My second article", "author": "Toto" }, 5 | { "id": "3", "title": "My third article", "author": "Manu" }, 6 | { "id": "4", "title": "My fourth article", "author": "Toto" } 7 | ], 8 | "profile": { "name": "Manu" } 9 | } 10 | -------------------------------------------------------------------------------- /examples/blog/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { createStore, applyMiddleware, compose } from 'redux' 4 | import { Provider } from 'react-redux' 5 | import thunk from 'redux-thunk' 6 | import { BrowserRouter as Router, Route } from 'react-router-dom' 7 | 8 | import ArticleList from './containers/ArticleList' 9 | import rootReducer from './reducers' 10 | import ArticlePage from './containers/ArticlePage' 11 | 12 | const devToolEnable = window.devToolsExtension 13 | 14 | const store = createStore( 15 | rootReducer, 16 | compose( 17 | applyMiddleware(thunk), 18 | devToolEnable 19 | ? window.devToolsExtension({ 20 | actionsBlacklist: ['@@redux-form/REGISTER_FIELD'] 21 | }) 22 | : f => f 23 | ) 24 | ) 25 | 26 | render( 27 | 28 | 29 |
30 | 31 | 32 |
33 |
34 |
, 35 | document.getElementById('root') 36 | ) 37 | -------------------------------------------------------------------------------- /examples/blog/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { reducer as reduxListsReducer } from 'redux-lists' 3 | 4 | export default combineReducers({ 5 | reduxList: reduxListsReducer 6 | }) 7 | -------------------------------------------------------------------------------- /examples/blog/src/selectors/index.js: -------------------------------------------------------------------------------- 1 | import { getSelectors } from 'redux-lists' 2 | 3 | export const { 4 | listSelector: articlesListSelector, 5 | byKeySelector: articleByIdSelector 6 | } = getSelectors('ARTICLES') 7 | -------------------------------------------------------------------------------- /examples/todos/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # production 7 | build 8 | 9 | # misc 10 | .DS_Store 11 | npm-debug.log 12 | -------------------------------------------------------------------------------- /examples/todos/README.md: -------------------------------------------------------------------------------- 1 | # Redux Todos Example 2 | 3 | This project template was built with [Create React App](https://github.com/facebookincubator/create-react-app), which provides a simple way to start React projects with no build configuration needed. 4 | 5 | Projects built with Create-React-App include support for ES6 syntax, as well as several unofficial / not-yet-final forms of Javascript syntax such as Class Properties and JSX. See the list of [language features and polyfills supported by Create-React-App](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#supported-language-features-and-polyfills) for more information. 6 | 7 | ## Available Scripts 8 | 9 | In the project directory, you can run: 10 | 11 | ### `npm start` 12 | 13 | Runs the app in the development mode.
14 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 15 | 16 | The page will reload if you make edits.
17 | You will also see any lint errors in the console. 18 | 19 | ### `npm run build` 20 | 21 | Builds the app for production to the `build` folder.
22 | It correctly bundles React in production mode and optimizes the build for the best performance. 23 | 24 | The build is minified and the filenames include the hashes.
25 | Your app is ready to be deployed! 26 | 27 | ### `npm run eject` 28 | 29 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 30 | 31 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 32 | 33 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 34 | 35 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 36 | 37 | -------------------------------------------------------------------------------- /examples/todos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todos", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "^1.1.4" 7 | }, 8 | "dependencies": { 9 | "prop-types": "^15.6.1", 10 | "react": "^16.3.1", 11 | "react-dom": "^16.3.1", 12 | "react-redux": "^5.0.7", 13 | "redux": "^3.5.2", 14 | "redux-thunk": "^2.2.0", 15 | "redux-lists": "^1.0.0" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "eject": "react-scripts eject", 21 | "test": "react-scripts test" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/todos/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Todos - Redux-lists 7 | 8 | 9 |
10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/todos/src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { getActionCreators } from 'redux-lists' 2 | import { todoByIdSelector, todoListSelector } from '../selectors' 3 | 4 | export const { 5 | setList: setTodoList, 6 | updateItems: updateTodos 7 | } = getActionCreators('TODOS') 8 | 9 | export const addTodo = todo => (dispatch, getState) => { 10 | const state = getState() 11 | const currentTodos = todoListSelector(state, LISTS.ALL) || [] 12 | const newTodos = [...currentTodos, todo] 13 | 14 | dispatch(setTodoList(newTodos, LISTS.ALL)) 15 | } 16 | 17 | const filterTodos = (todos = [], filter) => { 18 | if (filter === LISTS.ALL) return todos 19 | const keepActive = filter === LISTS.ACTIVE 20 | 21 | return todos.filter(todo => { 22 | if (keepActive) { 23 | return !todo.completed 24 | } 25 | 26 | return todo.completed 27 | }) 28 | } 29 | 30 | export const setVisibilityFilter = filter => (dispatch, getState) => { 31 | dispatch({ 32 | type: 'SET_VISIBILITY_FILTER', 33 | filter 34 | }) 35 | 36 | const state = getState() 37 | const allTodos = todoListSelector(state, LISTS.ALL) 38 | const todos = filterTodos(allTodos, filter) 39 | dispatch(setTodoList(todos, filter)) 40 | } 41 | 42 | export const toggleTodo = id => async (dispatch, getState) => { 43 | const state = getState() 44 | const todo = todoByIdSelector(state, id) 45 | 46 | await dispatch( 47 | updateTodos({ 48 | ...todo, 49 | completed: !todo.completed 50 | }) 51 | ) 52 | 53 | if (state.visibilityFilter !== LISTS.ALL) { 54 | const newState = getState() 55 | const allTodos = todoListSelector(newState, LISTS.ALL) 56 | const todos = filterTodos(allTodos, state.visibilityFilter) 57 | dispatch(setTodoList(todos, state.visibilityFilter)) 58 | } 59 | } 60 | 61 | export const LISTS = { 62 | ALL: 'ALL', 63 | COMPLETED: 'COMPLETED', 64 | ACTIVE: 'ACTIVE' 65 | } 66 | -------------------------------------------------------------------------------- /examples/todos/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Footer from './Footer' 3 | import AddTodo from '../containers/AddTodo' 4 | import VisibleTodoList from '../containers/VisibleTodoList' 5 | 6 | const App = () => ( 7 |
8 | 9 | 10 |
12 | ) 13 | 14 | export default App 15 | -------------------------------------------------------------------------------- /examples/todos/src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import FilterLink from '../containers/FilterLink' 3 | import { LISTS } from '../actions' 4 | 5 | const Footer = () => ( 6 |
7 | Show: 8 | All 9 | Active 10 | Completed 11 |
12 | ) 13 | 14 | export default Footer 15 | -------------------------------------------------------------------------------- /examples/todos/src/components/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Link = ({ active, children, onClick }) => ( 5 | 14 | ) 15 | 16 | Link.propTypes = { 17 | active: PropTypes.bool.isRequired, 18 | children: PropTypes.node.isRequired, 19 | onClick: PropTypes.func.isRequired 20 | } 21 | 22 | export default Link 23 | -------------------------------------------------------------------------------- /examples/todos/src/components/Todo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Todo = ({ onClick, completed, text }) => ( 5 |
  • 11 | {text} 12 |
  • 13 | ) 14 | 15 | Todo.propTypes = { 16 | onClick: PropTypes.func.isRequired, 17 | completed: PropTypes.bool.isRequired, 18 | text: PropTypes.string.isRequired 19 | } 20 | 21 | export default Todo 22 | -------------------------------------------------------------------------------- /examples/todos/src/components/TodoList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Todo from './Todo' 4 | 5 | const TodoList = ({ todos, toggleTodo }) => { 6 | if (!todos) return null 7 | 8 | return ( 9 | 18 | ) 19 | } 20 | 21 | TodoList.propTypes = { 22 | todos: PropTypes.arrayOf( 23 | PropTypes.shape({ 24 | id: PropTypes.number.isRequired, 25 | completed: PropTypes.bool.isRequired, 26 | text: PropTypes.string.isRequired 27 | }).isRequired 28 | ), 29 | toggleTodo: PropTypes.func.isRequired 30 | } 31 | 32 | export default TodoList 33 | -------------------------------------------------------------------------------- /examples/todos/src/containers/AddTodo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | 5 | import { addTodo } from '../actions' 6 | 7 | let countId = 0 8 | 9 | const AddTodo = ({ addTodo }) => { 10 | let input 11 | 12 | return ( 13 |
    14 |
    { 16 | e.preventDefault() 17 | if (!input.value.trim()) { 18 | return 19 | } 20 | addTodo({ 21 | id: countId++, 22 | text: input.value, 23 | completed: false 24 | }) 25 | input.value = '' 26 | }} 27 | > 28 | (input = node)} /> 29 | 30 |
    31 |
    32 | ) 33 | } 34 | 35 | AddTodo.propTypes = { 36 | addTodo: PropTypes.func 37 | } 38 | 39 | const mapDispatchToProps = { addTodo } 40 | 41 | export default connect(null, mapDispatchToProps)(AddTodo) 42 | -------------------------------------------------------------------------------- /examples/todos/src/containers/FilterLink.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { setVisibilityFilter } from '../actions' 3 | import Link from '../components/Link' 4 | 5 | const mapStateToProps = (state, ownProps) => ({ 6 | active: ownProps.filter === state.visibilityFilter 7 | }) 8 | 9 | const mapDispatchToProps = (dispatch, ownProps) => ({ 10 | onClick: () => dispatch(setVisibilityFilter(ownProps.filter)) 11 | }) 12 | 13 | export default connect(mapStateToProps, mapDispatchToProps)(Link) 14 | -------------------------------------------------------------------------------- /examples/todos/src/containers/VisibleTodoList.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { toggleTodo } from '../actions' 3 | import TodoList from '../components/TodoList' 4 | import { todoListSelector } from '../selectors' 5 | 6 | const mapStateToProps = state => ({ 7 | todos: todoListSelector(state, state.visibilityFilter) 8 | }) 9 | 10 | const mapDispatchToProps = dispatch => ({ 11 | toggleTodo: id => dispatch(toggleTodo(id)) 12 | }) 13 | 14 | export default connect(mapStateToProps, mapDispatchToProps)(TodoList) 15 | -------------------------------------------------------------------------------- /examples/todos/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { createStore, applyMiddleware, compose } from 'redux' 4 | import { Provider } from 'react-redux' 5 | import thunk from 'redux-thunk' 6 | 7 | import App from './components/App' 8 | import rootReducer from './reducers' 9 | 10 | const devToolEnable = window.devToolsExtension 11 | 12 | const store = createStore( 13 | rootReducer, 14 | compose( 15 | applyMiddleware(thunk), 16 | devToolEnable 17 | ? window.devToolsExtension({ 18 | actionsBlacklist: ['@@redux-form/REGISTER_FIELD'] 19 | }) 20 | : f => f 21 | ) 22 | ) 23 | 24 | render( 25 | 26 | 27 | , 28 | document.getElementById('root') 29 | ) 30 | -------------------------------------------------------------------------------- /examples/todos/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { reducer as reduxListsReducer } from 'redux-lists' 3 | 4 | import visibilityFilter from './visibilityFilter' 5 | 6 | export default combineReducers({ 7 | visibilityFilter, 8 | reduxList: reduxListsReducer 9 | }) 10 | -------------------------------------------------------------------------------- /examples/todos/src/reducers/visibilityFilter.js: -------------------------------------------------------------------------------- 1 | import { LISTS } from '../actions' 2 | 3 | const visibilityFilter = (state = LISTS.ALL, action) => { 4 | switch (action.type) { 5 | case 'SET_VISIBILITY_FILTER': 6 | return action.filter 7 | default: 8 | return state 9 | } 10 | } 11 | 12 | export default visibilityFilter 13 | -------------------------------------------------------------------------------- /examples/todos/src/selectors/index.js: -------------------------------------------------------------------------------- 1 | import { getSelectors } from 'redux-lists' 2 | 3 | export const { 4 | listSelector: todoListSelector, 5 | byKeySelector: todoByIdSelector 6 | } = getSelectors('TODOS') 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-lists", 3 | "version": "1.0.0", 4 | "description": "A collection organizer using lists and maps to manage data entities", 5 | "main": "lib/index.js", 6 | "jsnext:main": "es/index.js", 7 | "authors": [ 8 | "Manuel BEAUDRU (https://github.com/mbeaudru)", 9 | "Thomas BERTET (https://github.com/thomasbertet)" 10 | ], 11 | "repository": "github:mirakl/redux-lists", 12 | "files": [ 13 | "es", 14 | "lib", 15 | "dist", 16 | "README" 17 | ], 18 | "license": "MIT", 19 | "scripts": { 20 | "test": "jest", 21 | "clean": "rimraf lib dist es", 22 | "build": "npm run build:commonjs && npm run build:umd && npm run build:umd:min && npm run build:es && npm run build:remove-mocks", 23 | "prepublish": "npm run clean && npm run test && npm run build", 24 | "posttest": "npm run lint", 25 | "lint": "eslint src test", 26 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib --ignore __tests__", 27 | "build:es": "cross-env BABEL_ENV=es babel src --out-dir es --ignore __tests__", 28 | "build:umd": "cross-env BABEL_ENV=commonjs NODE_ENV=development webpack", 29 | "build:umd:min": "cross-env BABEL_ENV=commonjs NODE_ENV=production webpack", 30 | "build:remove-mocks": "rm -rf es/**/__mocks__ && rm -rf lib/**/__mocks__ && rm -rf es/__mocks__ && rm -rf lib/__mocks__", 31 | "readme-toc": "markdown-toc readme.md" 32 | }, 33 | "devDependencies": { 34 | "babel-cli": "6.26.0", 35 | "babel-loader": "7.1.4", 36 | "babel-plugin-transform-object-rest-spread": "6.26.0", 37 | "babel-preset-latest": "6.24.1", 38 | "cross-env": "5.1.4", 39 | "eslint": "4.19.1", 40 | "eslint-config-standard": "11.0.0", 41 | "eslint-config-standard-react": "6.0.0", 42 | "eslint-plugin-import": "2.13.0", 43 | "eslint-plugin-node": "6.0.1", 44 | "eslint-plugin-promise": "3.8.0", 45 | "eslint-plugin-react": "7.10.0", 46 | "eslint-plugin-standard": "3.1.0", 47 | "jest": "22.4.3", 48 | "markdown-toc": "1.2.0", 49 | "prettier": "1.12.1", 50 | "release-it": "7.4.4", 51 | "webpack": "4.6.0", 52 | "webpack-cli": "2.0.14" 53 | }, 54 | "dependencies": { 55 | "lodash": "4.17.5", 56 | "reselect": "3.0.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/__mocks__/index.js: -------------------------------------------------------------------------------- 1 | export const TODOS_NAMESPACE = 'todos' 2 | 3 | export const ITEM_1 = { 4 | id: '1', 5 | value: 'Buy some bananas', 6 | done: false 7 | } 8 | export const ITEM_2 = { 9 | id: '2', 10 | value: 'Do the dishes', 11 | done: true 12 | } 13 | 14 | export const state = { 15 | [TODOS_NAMESPACE]: { 16 | lists: { 17 | ALL: [ITEM_1.id, ITEM_2.id], 18 | DONE: [ITEM_2.id], 19 | NOT_DONE: [ITEM_1.id] 20 | }, 21 | map: { 22 | [ITEM_1.id]: ITEM_1, 23 | [ITEM_2.id]: ITEM_2 24 | } 25 | } 26 | } 27 | 28 | export const fullState = { 29 | reduxList: state 30 | } 31 | -------------------------------------------------------------------------------- /src/__tests__/getActionCreators.spec.jest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { ITEM_1, ITEM_2, TODOS_NAMESPACE } from '../__mocks__' 4 | import getActionCreators from '../getActionCreators' 5 | import { SET_LIST, UPDATE_ITEMS } from '../actionTypeHelpers' 6 | 7 | describe('getActionCreators', () => { 8 | it('should create setList action creator with optional key', () => { 9 | const { setList: setTodoList } = getActionCreators(TODOS_NAMESPACE) 10 | 11 | const createAllTodoListAction = setTodoList([ITEM_1, ITEM_2], 'ALL') 12 | expect(createAllTodoListAction).toEqual({ 13 | type: SET_LIST(TODOS_NAMESPACE, 'ALL'), 14 | listName: 'ALL', 15 | items: [ITEM_1, ITEM_2], 16 | onKey: 'id', 17 | namespace: TODOS_NAMESPACE 18 | }) 19 | }) 20 | 21 | it('should create updateItems action creator with optional key', () => { 22 | const { updateItems: updateTodos } = getActionCreators(TODOS_NAMESPACE) 23 | 24 | const UPDATED_ITEM_1 = { 25 | ...ITEM_1, 26 | done: true 27 | } 28 | 29 | const updateSingularTodoAction = updateTodos(UPDATED_ITEM_1) 30 | 31 | expect(updateSingularTodoAction).toEqual({ 32 | type: UPDATE_ITEMS(TODOS_NAMESPACE), 33 | namespace: TODOS_NAMESPACE, 34 | items: [UPDATED_ITEM_1], 35 | onKey: 'id' 36 | }) 37 | 38 | const UPDATED_ITEM_2 = { 39 | ...ITEM_2, 40 | done: false 41 | } 42 | 43 | const updateMultipleTodosAction = updateTodos([ 44 | UPDATED_ITEM_1, 45 | UPDATED_ITEM_2 46 | ]) 47 | 48 | expect(updateMultipleTodosAction).toEqual({ 49 | type: UPDATE_ITEMS(TODOS_NAMESPACE), 50 | namespace: TODOS_NAMESPACE, 51 | items: [UPDATED_ITEM_1, UPDATED_ITEM_2], 52 | onKey: 'id' 53 | }) 54 | }) 55 | 56 | it('should handle custom key', () => { 57 | const { setList, updateItems } = getActionCreators(TODOS_NAMESPACE, { 58 | onKey: 'code' 59 | }) 60 | 61 | const createAllTodoListAction = setList([ITEM_1, ITEM_2], 'ALL') 62 | expect(createAllTodoListAction).toEqual({ 63 | type: SET_LIST(TODOS_NAMESPACE, 'ALL'), 64 | listName: 'ALL', 65 | items: [ITEM_1, ITEM_2], 66 | onKey: 'code', 67 | namespace: TODOS_NAMESPACE 68 | }) 69 | 70 | const UPDATED_ITEM_1 = { 71 | ...ITEM_1, 72 | done: true 73 | } 74 | 75 | const updateTodoAction = updateItems(UPDATED_ITEM_1) 76 | 77 | expect(updateTodoAction).toEqual({ 78 | type: UPDATE_ITEMS(TODOS_NAMESPACE), 79 | namespace: TODOS_NAMESPACE, 80 | items: [UPDATED_ITEM_1], 81 | onKey: 'code' 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/__tests__/getSelectors.spec.jest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { 4 | ITEM_1, 5 | ITEM_2, 6 | fullState as state, 7 | TODOS_NAMESPACE 8 | } from '../__mocks__' 9 | import getSelectors from '../getSelectors' 10 | 11 | describe('getSelectors', () => { 12 | it('should create redux-lists selectors with the right namespace', () => { 13 | const { listSelector, byKeySelector } = getSelectors(TODOS_NAMESPACE) 14 | 15 | const allTodos = listSelector(state, 'ALL') 16 | expect(allTodos).toEqual([ITEM_1, ITEM_2]) 17 | 18 | const doneTodos = listSelector(state, 'DONE') 19 | expect(doneTodos).toEqual([ITEM_2]) 20 | 21 | const firstTodo = byKeySelector(state, ITEM_1.id) 22 | expect(firstTodo).toEqual(ITEM_1) 23 | }) 24 | 25 | it('should memoize the results', () => { 26 | const { listSelector, byKeySelector } = getSelectors(TODOS_NAMESPACE) 27 | 28 | expect(listSelector(state, 'ALL')).toBe(listSelector(state, 'ALL')) 29 | expect(listSelector(state, 'DONE')).toBe(listSelector(state, 'DONE')) 30 | expect(byKeySelector(state, ITEM_1.id)).toBe( 31 | byKeySelector(state, ITEM_1.id) 32 | ) 33 | }) 34 | 35 | it('should not break without namespace parameter', () => { 36 | const { listSelector, byKeySelector } = getSelectors() 37 | 38 | const allTodos = listSelector(state, 'ALL') 39 | expect(allTodos).toEqual(undefined) 40 | 41 | const doneTodos = listSelector(state, 'DONE') 42 | expect(doneTodos).toEqual(undefined) 43 | 44 | const firstTodo = byKeySelector(state, ITEM_1.id) 45 | expect(firstTodo).toEqual(undefined) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/__tests__/itemsByListNameSelectorFactory.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { TODOS_NAMESPACE, ITEM_1, ITEM_2, state } from '../__mocks__/index' 4 | import { selectorsFactory } from '../selectorsFactory' 5 | 6 | describe('itemsByListNameSelectorFactory', () => { 7 | it('should return the entity list by listName', () => { 8 | const { itemsByListNameSelectorFactory } = selectorsFactory( 9 | TODOS_NAMESPACE 10 | ) 11 | 12 | const allTodosSelector = itemsByListNameSelectorFactory('ALL') 13 | expect(allTodosSelector).toBeInstanceOf(Function) 14 | 15 | const allTodos = allTodosSelector(state) 16 | expect(allTodos).toEqual([ITEM_1, ITEM_2]) 17 | 18 | const doneTodosSelector = itemsByListNameSelectorFactory('DONE') 19 | expect(doneTodosSelector).toBeInstanceOf(Function) 20 | 21 | const doneTodos = doneTodosSelector(state) 22 | expect(doneTodos).toEqual([ITEM_2]) 23 | }) 24 | 25 | it('should memoize the returned value', () => { 26 | const { itemsByListNameSelectorFactory } = selectorsFactory( 27 | TODOS_NAMESPACE 28 | ) 29 | 30 | const allTodosSelector = itemsByListNameSelectorFactory('ALL') 31 | expect(allTodosSelector(state)).toBe(allTodosSelector(state)) 32 | 33 | const doneTodosSelector = itemsByListNameSelectorFactory('DONE') 34 | expect(doneTodosSelector(state)).toBe(doneTodosSelector(state)) 35 | }) 36 | 37 | it('should return undefined when list do not exists', () => { 38 | const { itemsByListNameSelectorFactory } = selectorsFactory( 39 | TODOS_NAMESPACE 40 | ) 41 | const imaginaryListSelector = itemsByListNameSelectorFactory( 42 | 'NO EXISTING LIST' 43 | ) 44 | 45 | const allTodos = imaginaryListSelector(state) 46 | expect(allTodos).toEqual(undefined) 47 | }) 48 | 49 | it('should return undefined with empty args', () => { 50 | const { itemsByListNameSelectorFactory } = selectorsFactory( 51 | TODOS_NAMESPACE 52 | ) 53 | expect(itemsByListNameSelectorFactory()()).toEqual(undefined) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/__tests__/listSelectorFactory.spec.jest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { TODOS_NAMESPACE, ITEM_1, ITEM_2, state } from '../__mocks__/index' 4 | import { selectorsFactory } from '../selectorsFactory' 5 | 6 | describe('listSelectorFactory', () => { 7 | it('should return the entity list by listName', () => { 8 | const { listSelectorFactory } = selectorsFactory(TODOS_NAMESPACE) 9 | 10 | const allTodosIdsSelector = listSelectorFactory('ALL') 11 | expect(allTodosIdsSelector).toBeInstanceOf(Function) 12 | 13 | const allTodos = allTodosIdsSelector(state) 14 | expect(allTodos).toEqual([ITEM_1.id, ITEM_2.id]) 15 | 16 | const doneTodosIdsSelector = listSelectorFactory('DONE') 17 | expect(doneTodosIdsSelector).toBeInstanceOf(Function) 18 | 19 | const doneTodos = doneTodosIdsSelector(state) 20 | expect(doneTodos).toEqual([ITEM_2.id]) 21 | }) 22 | 23 | it('should memoize the returned value', () => { 24 | const { listSelectorFactory } = selectorsFactory(TODOS_NAMESPACE) 25 | 26 | const allTodosSelector = listSelectorFactory('ALL') 27 | expect(allTodosSelector(state)).toBe(allTodosSelector(state)) 28 | 29 | const doneTodosSelector = listSelectorFactory('DONE') 30 | expect(doneTodosSelector(state)).toBe(doneTodosSelector(state)) 31 | }) 32 | 33 | it('should work with empty args', () => { 34 | const { listSelectorFactory } = selectorsFactory(TODOS_NAMESPACE) 35 | expect(listSelectorFactory()()).toEqual(undefined) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/__tests__/mapSelector.spec.jest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { TODOS_NAMESPACE, ITEM_1, ITEM_2, state } from '../__mocks__/index' 4 | import { selectorsFactory } from '../selectorsFactory' 5 | 6 | describe('mapSelector', () => { 7 | it('should return the entity map', () => { 8 | const { mapSelector: todoMapSelector } = selectorsFactory( 9 | TODOS_NAMESPACE 10 | ) 11 | 12 | expect(todoMapSelector).toBeInstanceOf(Function) 13 | expect(todoMapSelector(state)).toEqual({ 14 | [ITEM_1.id]: ITEM_1, 15 | [ITEM_2.id]: ITEM_2 16 | }) 17 | }) 18 | 19 | it('should always return the same reference', () => { 20 | const { mapSelector } = selectorsFactory(TODOS_NAMESPACE) 21 | 22 | expect(mapSelector(state)).toBe(mapSelector(state)) 23 | }) 24 | 25 | it('should return empty map', () => { 26 | const { mapSelector } = selectorsFactory(TODOS_NAMESPACE) 27 | 28 | expect(mapSelector).toBeInstanceOf(Function) 29 | expect(mapSelector()).toEqual({}) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/__tests__/reducer.spec.jest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import reduxListReducer from '../reducer' 4 | import getActionCreators from '../getActionCreators' 5 | import { ITEM_1, ITEM_2, TODOS_NAMESPACE } from '../__mocks__' 6 | 7 | const state = {} 8 | 9 | describe('Redux-lists reducer', () => { 10 | it('should return state if no action type matches', () => { 11 | const updatedState = reduxListReducer(state, {}) 12 | expect(updatedState).toEqual(state) 13 | }) 14 | 15 | it('should react properly to setList action', () => { 16 | const { setList: setTodoList } = getActionCreators(TODOS_NAMESPACE) 17 | 18 | const setAllTodosAction = setTodoList([ITEM_1, ITEM_2], 'ALL') 19 | const newState = reduxListReducer(state, setAllTodosAction) 20 | 21 | expect(newState).toEqual({ 22 | [TODOS_NAMESPACE]: { 23 | lists: { 24 | ALL: [ITEM_1.id, ITEM_2.id] 25 | }, 26 | map: { 27 | [ITEM_1.id]: ITEM_1, 28 | [ITEM_2.id]: ITEM_2 29 | } 30 | } 31 | }) 32 | 33 | const setDoneTodosAction = setTodoList([ITEM_2], 'DONE') 34 | const newState2 = reduxListReducer(newState, setDoneTodosAction) 35 | 36 | expect(newState2).toEqual({ 37 | [TODOS_NAMESPACE]: { 38 | lists: { 39 | ALL: [ITEM_1.id, ITEM_2.id], 40 | DONE: [ITEM_2.id] 41 | }, 42 | map: { 43 | [ITEM_1.id]: ITEM_1, 44 | [ITEM_2.id]: ITEM_2 45 | } 46 | } 47 | }) 48 | }) 49 | 50 | it('should react properly to updateItems action', () => { 51 | const { updateItems: updateTodos } = getActionCreators(TODOS_NAMESPACE) 52 | const state = { 53 | [TODOS_NAMESPACE]: { 54 | lists: { 55 | ALL: [ITEM_1.id, ITEM_2.id], 56 | DONE: [ITEM_2.id] 57 | }, 58 | map: { 59 | [ITEM_1.id]: ITEM_1, 60 | [ITEM_2.id]: ITEM_2 61 | } 62 | } 63 | } 64 | 65 | const UPDATED_ITEM_1 = { 66 | ...ITEM_1, 67 | done: true 68 | } 69 | 70 | const updateTodoAction = updateTodos(UPDATED_ITEM_1) 71 | const newState = reduxListReducer(state, updateTodoAction) 72 | 73 | expect(newState).toEqual({ 74 | [TODOS_NAMESPACE]: { 75 | lists: { 76 | ALL: [ITEM_1.id, ITEM_2.id], 77 | DONE: [ITEM_2.id] 78 | }, 79 | map: { 80 | [ITEM_1.id]: UPDATED_ITEM_1, 81 | [ITEM_2.id]: ITEM_2 82 | } 83 | } 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/__tests__/selectorsFactory.spec.jest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { selectorsFactory } from '../selectorsFactory' 4 | import { TODOS_NAMESPACE } from '../__mocks__/index' 5 | 6 | describe('selectorsFactory', () => { 7 | it('should generate some selectors and factories', () => { 8 | const { 9 | mapSelector, 10 | listSelectorFactory, 11 | itemsByListNameSelectorFactory 12 | } = selectorsFactory(TODOS_NAMESPACE) 13 | 14 | expect(mapSelector).toBeInstanceOf(Function) 15 | expect(mapSelector()).toBeInstanceOf(Object) 16 | 17 | expect(listSelectorFactory).toBeInstanceOf(Function) 18 | expect(listSelectorFactory()).toBeInstanceOf(Function) 19 | expect(listSelectorFactory()()).not.toBeInstanceOf(Function) 20 | 21 | expect(itemsByListNameSelectorFactory).toBeInstanceOf(Function) 22 | expect(itemsByListNameSelectorFactory()).toBeInstanceOf(Function) 23 | expect(itemsByListNameSelectorFactory()()).not.toBeInstanceOf(Function) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/actionTypeHelpers.js: -------------------------------------------------------------------------------- 1 | const PREFIX = '@@redux-lists' 2 | 3 | export const SET_LIST = (namespace, listName) => 4 | `${PREFIX}/SET_LIST: ${listName} in ${namespace}` 5 | export const UPDATE_ITEMS = namespace => `${PREFIX}/UPDATE_ITEMS: ${namespace}` 6 | -------------------------------------------------------------------------------- /src/getActionCreators.js: -------------------------------------------------------------------------------- 1 | import { SET_LIST, UPDATE_ITEMS } from './actionTypeHelpers' 2 | 3 | const getActionCreators = (namespace, { onKey = 'id' } = {}) => { 4 | return { 5 | setList: (itemsParams, listName) => { 6 | const items = Array.isArray(itemsParams) 7 | ? itemsParams 8 | : [itemsParams] 9 | return { 10 | type: SET_LIST(namespace, listName), 11 | listName, 12 | items, 13 | onKey, 14 | namespace 15 | } 16 | }, 17 | updateItems: itemsParams => { 18 | const items = Array.isArray(itemsParams) 19 | ? itemsParams 20 | : [itemsParams] 21 | return { 22 | type: UPDATE_ITEMS(namespace), 23 | namespace, 24 | items, 25 | onKey 26 | } 27 | } 28 | } 29 | } 30 | 31 | export default getActionCreators 32 | -------------------------------------------------------------------------------- /src/getSelectors.js: -------------------------------------------------------------------------------- 1 | import _get from 'lodash/get' 2 | import { selectorsFactory } from './selectorsFactory' 3 | 4 | const subState = state => _get(state, 'reduxList', {}) 5 | 6 | const getSelectors = namespace => { 7 | const { itemsByListNameSelectorFactory, mapSelector } = selectorsFactory( 8 | namespace 9 | ) 10 | 11 | return { 12 | listSelector: (state, listName) => 13 | itemsByListNameSelectorFactory(listName)(subState(state)), 14 | byKeySelector: (state, itemKey) => 15 | mapSelector(subState(state))[itemKey] 16 | } 17 | } 18 | 19 | export default getSelectors 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import reducer from './reducer' 2 | import getSelectors from './getSelectors' 3 | import getActionCreators from './getActionCreators' 4 | 5 | export { reducer, getSelectors, getActionCreators } 6 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import _get from 'lodash/get' 2 | import { SET_LIST, UPDATE_ITEMS } from './actionTypeHelpers' 3 | 4 | const INITIAL_STATE = {} 5 | 6 | const reduxList = (state = INITIAL_STATE, action) => { 7 | const { namespace, listName } = action 8 | switch (action.type) { 9 | case SET_LIST(namespace, listName): { 10 | const map = {} 11 | const list = [] 12 | 13 | action.items.forEach(item => { 14 | const key = item[action.onKey] 15 | map[key] = item 16 | list.push(key) 17 | }) 18 | 19 | return { 20 | ...state, 21 | [namespace]: { 22 | map: { 23 | ..._get(state, [namespace, 'map'], {}), 24 | ...map 25 | }, 26 | lists: { 27 | ..._get(state, [namespace, 'lists'], {}), 28 | [listName]: list 29 | } 30 | } 31 | } 32 | } 33 | case UPDATE_ITEMS(namespace): { 34 | const map = {} 35 | 36 | action.items.forEach(item => { 37 | const key = item[action.onKey] 38 | map[key] = item 39 | }) 40 | 41 | return { 42 | ...state, 43 | [namespace]: { 44 | lists: _get(state, [namespace, 'lists'], {}), 45 | map: { 46 | ..._get(state, [namespace, 'map'], {}), 47 | ...map 48 | } 49 | } 50 | } 51 | } 52 | default: 53 | return state 54 | } 55 | } 56 | 57 | export default reduxList 58 | -------------------------------------------------------------------------------- /src/selectorsFactory.js: -------------------------------------------------------------------------------- 1 | import _get from 'lodash/get' 2 | import _memoize from 'lodash/memoize' 3 | import { createSelector } from 'reselect' 4 | 5 | const getNamespacedStore = (state, namespace) => _get(state, namespace, {}) 6 | 7 | export const selectorsFactory = namespace => { 8 | const mapSelector = createSelector( 9 | state => getNamespacedStore(state, namespace), 10 | nsStore => _get(nsStore, 'map', {}) 11 | ) 12 | 13 | const listSelectorFactory = _memoize(listName => 14 | createSelector( 15 | state => getNamespacedStore(state, namespace), 16 | nsStore => _get(nsStore, ['lists', listName], undefined) 17 | ) 18 | ) 19 | 20 | const itemsByListNameSelectorFactory = _memoize(listName => 21 | createSelector( 22 | state => listSelectorFactory(listName)(state), 23 | state => mapSelector(state), 24 | (itemsIdsList, itemsMap) => 25 | itemsIdsList 26 | ? itemsIdsList.map(id => _get(itemsMap, id, {})) 27 | : undefined 28 | ) 29 | ) 30 | 31 | return { 32 | mapSelector, 33 | listSelectorFactory, 34 | itemsByListNameSelectorFactory 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack' 2 | import path from 'path' 3 | 4 | const { NODE_ENV } = process.env 5 | 6 | const plugins = [ 7 | new webpack.DefinePlugin({ 8 | 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) 9 | }) 10 | ] 11 | 12 | const filename = `redux-list${NODE_ENV === 'production' ? '.min' : ''}.js` 13 | 14 | export default { 15 | mode: NODE_ENV, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | loaders: ['babel-loader'], 21 | exclude: /node_modules/ 22 | } 23 | ] 24 | }, 25 | 26 | optimization: { 27 | minimize: true 28 | }, 29 | 30 | entry: ['./src/index'], 31 | 32 | output: { 33 | path: path.join(__dirname, 'dist'), 34 | filename, 35 | library: 'ReduxList', 36 | libraryTarget: 'umd' 37 | }, 38 | 39 | plugins 40 | } 41 | --------------------------------------------------------------------------------