├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── examples └── todomvc │ ├── README.md │ ├── containers │ └── TodoApp │ │ ├── components │ │ └── TodoList │ │ │ ├── components │ │ │ ├── Footer.jsx │ │ │ ├── Header.jsx │ │ │ ├── MarkAll.jsx │ │ │ └── Todo.jsx │ │ │ └── index.jsx │ │ └── index.jsx │ ├── index.html │ ├── index.js │ ├── legacy │ ├── app_view.jsx │ ├── router.js │ ├── todo.js │ └── todos.js │ ├── package.json │ ├── server.js │ └── webpack.config.js ├── package.json ├── src ├── action-fabric.js ├── collection-tools.js ├── ear-fabric.js ├── index.js ├── model-add-batcher.js ├── reducer-fabric.js └── reducer-tools.js ├── test ├── .eslintrc ├── action-fabric-test.js ├── collection-tools-test.js ├── reducer-fabric-test.js └── reducer-tools-test.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | **/node_modules 3 | **/webpack.config.js 4 | examples/**/server.js 5 | examples/**/node_modules 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "func-names": 0, 10 | "id-length": 0, 11 | "react/jsx-uses-react": 2, 12 | "react/jsx-uses-vars": 2, 13 | "react/react-in-jsx-scope": 2 14 | }, 15 | "plugins": [ 16 | "react" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | dist 5 | lib 6 | coverage 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | src 4 | test 5 | examples 6 | coverage 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.1.2" 4 | script: 5 | - npm run ci 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | Every release, along with the migration instructions, is documented on the [Github Releases page](https://github.com/redbooth/backbone-redux/releases). 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Ilya Zayats 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 | backbone-redux 2 | =============== 3 | 4 | The easy way to keep your backbone collections and redux store in sync. 5 | 6 | [![npm](https://img.shields.io/npm/v/backbone-redux.svg?style=flat-square)](https://www.npmjs.com/package/backbone-redux) 7 | [![npm](https://img.shields.io/npm/dm/backbone-redux.svg?style=flat-square)](https://www.npmjs.com/package/backbone-redux) 8 | [![Travis](https://img.shields.io/travis/redbooth/backbone-redux.svg?style=flat-square)](https://travis-ci.org/redbooth/backbone-redux) 9 | 10 | ``` 11 | npm install backbone-redux --save 12 | ``` 13 | 14 | Creates reducers and listeners for your backbone collections and fires action 15 | creators on every collection change. 16 | 17 | **Documentation is a work-in-progress**. Feedback is welcome and encouraged. 18 | 19 | * [Why?](#why) 20 | * [How to use](#how-to-use) 21 | * [Auto way](#auto-way) 22 | * [Manual artesanal way](#manual-artesanal-way) 23 | * [Documentation](#documentation) 24 | * [Configuration options](#configuration) 25 | * [collectionMap](#collection-map) 26 | * [indexesMap](#indexes-map) 27 | * [serializer](#serializer) 28 | * [API reference](#api-reference) 29 | * [syncCollections](#sync-collections) 30 | * [buildReducers](#build-reducers) 31 | * [buildEars](#build-ears) 32 | * [actionFabric](#action-fabric) 33 | * [reducerFabric](#reducer-fabric) 34 | * [Examples](#examples) 35 | 36 | 37 | ### Why? 38 | 39 | * You can start migrating your apps from backbone to react+redux in no time. 40 | * No need to worry about migrated/legacy parts of your app being out of sync, 41 | because both are using the single source of truth. 42 | * No boilerplate. 43 | * You can hide all new concepts like `reducers`, `stores`, `action creators`, 44 | `actions` and `purity` from other developers in your team to avoid brain 45 | overloading. 46 | * You have REST-adapter to your server out-of-the-box. Most React projects end 47 | up implementing an ad hoc, bug-ridden implementation of Backbone.Collection 48 | not only once, but for each store. 49 | * You have separation between server-data and UI-data. The later is flat, so 50 | working with it is a pleasure in React. 51 | 52 | ### How to use? 53 | #### Auto way 54 | 55 | 56 | ```javascript 57 | import { createStore, compose } from 'redux'; 58 | import { devTools } from 'redux-devtools'; 59 | import { syncCollections } from 'backbone-redux'; 60 | 61 | // Create your redux-store, include all middlewares you want. 62 | const finalCreateStore = compose(devTools())(createStore); 63 | const store = finalCreateStore(() => {}); // Store with an empty object as a reducer 64 | 65 | // Now just call auto-syncer from backbone-redux 66 | // Assuming you have Todos Backbone collection globally available 67 | syncCollections({todos: Todos}, store); 68 | ``` 69 | 70 | What will happen? 71 | 72 | * `syncCollections` will create a reducer under the hood especially for your 73 | collection. 74 | * `action creator` will be constructed with 4 possible actions: `add`, `merge`, 75 | `remove`, and `reset`. 76 | * Special `ear` object will be set up to listen to all collection events and 77 | trigger right actions depending on the event type. 78 | * Reducer will be registered in the store under `todos` key. 79 | * All previous reducers in your store will be replaced. 80 | 81 | You are done. Now any change to `Todos` collection will be reflected in the 82 | redux store. 83 | 84 | Models will be serialized before saving into the redux-tree: a result of 85 | calling `toJSON` on the model + field called `__optimistic_id` which is equal 86 | to model's `cid`; 87 | 88 | Resulting tree will look like this: 89 | 90 | ```javascript 91 | { 92 | todos: { 93 | entities: [{id: 1, ...}, {id: 2, ...}], 94 | by_id: { 95 | 1: {id: 1, ...}, 96 | 2: {id: 2, ...} 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | `entities` array is just an array of serialized models. `by_id` — default index 103 | which is created for you. It simplifies object retrieval, i.e.: 104 | `store.getState().todos.by_id[2]` 105 | 106 | So, what is happening when you change `Todos`? 107 | 108 | ``` 109 | something (your legacy/new UI or anything really) changes Todos 110 | -> Todos collection emits an event 111 | -> ear catches it 112 | -> ActionCreator emits an action 113 | -> Reducer creates a new state based on this action 114 | -> New State is stored and listeners are notified 115 | -> React doing its magic 116 | ``` 117 | 118 | #### Manual Artesanal Way 119 | 120 | Sometimes defaults that are provided by `syncCollections` are not enough. 121 | 122 | Reasons could vary: 123 | * your collection could not be globally available 124 | * you need some custom rules when adding/removing/resetting collection 125 | * your collection have any dependency that should be processed too 126 | * etc 127 | 128 | In all these cases you can't use `syncCollections`, but you can create your own 129 | ears to mimic `syncCollections` behavior. 130 | 131 | Any `ear` should look something like this: 132 | 133 | ```javascript 134 | import { bindActionCreators } from 'redux'; 135 | 136 | export default function(collection, rawActions, dispatch) { 137 | // binding action creators to the dispatch function 138 | const actions = bindActionCreators(rawActions, dispatch); 139 | 140 | actions.add(collection.models); // initial sync 141 | 142 | // adding listeners 143 | collection.on('add', actions.add); 144 | collection.on('change', actions.merge); 145 | collection.on('remove', actions.remove); 146 | collection.on('reset', ({models}) => actions.reset(models)); 147 | } 148 | ``` 149 | 150 | As you can see, `ear` requires 3 attributes. `collection` and `dispatch`(this 151 | is just `store.dispatch`) you normally should already have, but how we can 152 | generate `rawActions`? You can use `actionFabric` that `backbone-redux` 153 | provides: 154 | 155 | ```javascript 156 | import {actionFabric} from 'backbone-redux'; 157 | 158 | // create some constants that will be used as action types 159 | const constants = { 160 | ADD: 'ADD_MY_MODEL', 161 | REMOVE: 'REMOVE_MY_MODEL', 162 | MERGE: 'MERGE_MY_MODEL', 163 | RESET: 'RESET_MY_MODEL' 164 | }; 165 | 166 | // you need some serializer to prepare models to be stored in the store. 167 | // This is the default one that is used in backbone-redux, 168 | // but you can create totally your own, just don't forget about __optimistic_id 169 | const defaultSerializer = model => ({...model.toJSON(), __optimistic_id: model.cid}); 170 | 171 | export default actionFabric(constants, defaultSerializer); 172 | ``` 173 | 174 | Don't forget that `actionFabric` is just an object with a couple of methods, 175 | you can extend it as you want. 176 | 177 | Time to generate a reducer: 178 | 179 | ```javascript 180 | import {reducerFabric} from 'backbone-redux'; 181 | 182 | // the same constants, this is important 183 | const constants = { 184 | ADD: 'ADD_MY_MODEL', 185 | REMOVE: 'REMOVE_MY_MODEL', 186 | MERGE: 'MERGE_MY_MODEL', 187 | RESET: 'RESET_MY_MODEL' 188 | }; 189 | 190 | // any indexes that you want to be created for you 191 | const index_map = { 192 | fields: { 193 | by_id: 'id' 194 | }, 195 | relations: { 196 | by_channel_id: 'channel_id' 197 | } 198 | }; 199 | 200 | export default reducerFabric(constants, index_map); 201 | ``` 202 | 203 | 204 | And now we are ready to combine everything together: 205 | 206 | ```javascript 207 | import { syncCollections } from 'backbone-redux'; 208 | import store from './redux-store'; 209 | import customReducer from './reducer'; 210 | import customEar from './ear'; 211 | import customActions from './actions'; 212 | 213 | export default function() { 214 | // start with syncing normal collections 215 | const collectionsMap = { 216 | collection_that_does_not_need_customization: someCollection 217 | }; 218 | 219 | // we need to pass our prepared reducers into the store 220 | // if you don't use syncCollections at all, you just need 221 | // to create store normally with these reducers via 222 | // combineReducers from redux 223 | const extraReducers = { 224 | custom_collection: customReducer 225 | }; 226 | 227 | syncCollections(collectionsMap, store, extraReducers); 228 | 229 | // now let's call the ear 230 | customEar(customCollection, customActions, store.dispatch); 231 | } 232 | ``` 233 | 234 | Done, you have your custom ear placed and working. 235 | 236 | ## Documentation 237 | 238 | ### Configuration options 239 | 240 | #### collectionMap 241 | 242 | A collection map is a plain object passed to `backbone-redux` functions to set 243 | up reducers for you. 244 | 245 | If you don't need a custom serializer you can use: 246 | 247 | ```javascript 248 | // keys are reducer names, and values are backbone collections 249 | const collectionMap = { 250 | reducer_name: collection 251 | } 252 | ``` 253 | 254 | If you want, you can add change configuration by specifying `serializer` and `indexes_map` keys. 255 | 256 | ```javascript 257 | // keys are reducer names, and values are objects defining collection and serializer 258 | const collectionMap = { 259 | reducer_name: { 260 | collection: collection, 261 | serializer: serializer, 262 | indexes_map: indexes_map 263 | } 264 | } 265 | ``` 266 | 267 | #### indexesMap 268 | 269 | With `indexesMap` you can specify the way your entities are indexed in the tree. 270 | 271 | `fields` lets you access a *single* entity by a field (for example `id`, `email`, etc). 272 | 273 | `relation` groups entities by a field value (for example `parent_id`). 274 | 275 | Example: 276 | 277 | I have a `people` collection of models with 4 fields: `name`, 278 | `id`, `token`, and `org_id`. And I want to have indexes for all fields except 279 | `name`. 280 | 281 | ```javascript 282 | const jane = new Backbone.Model({id: 1, name: 'Jane', org_id: 1, token: '001'}); 283 | const mark = new Backbone.Model({id: 2, name: 'Mark', org_id: 2, token: '002'}); 284 | const sophy = new Backbone.Model({id: 3, name: 'Sophy', org_id: 1, token: '003'}); 285 | const people = new Backbone.Collection([jane, mark, sophy]); 286 | 287 | const indexesMap = { 288 | fields: { 289 | by_id: 'id', 290 | by_token: 'token' 291 | }, 292 | relations: { 293 | by_org_id: 'org_id' 294 | } 295 | }; 296 | 297 | syncCollections({ 298 | people: { 299 | collection: people, 300 | indexes_map: indexesMap 301 | } 302 | }, store); 303 | 304 | /** 305 | store.getState().people => 306 | 307 | { 308 | entities: [ 309 | {id: 1, name: 'Jane', org_id: 1, token: '001', __optimistic_id: 'c01'}, 310 | {id: 2, name: 'Mark', org_id: 2, token: '002', __optimistic_id: 'c02'}, 311 | {id: 3, name: 'Sophy', org_id: 1, token: '003', __optimistic_id: 'c03'} 312 | ], 313 | by_id: { 314 | 1: {id: 1, name: 'Jane', org_id: 1, token: '001', __optimistic_id: 'c01'}, 315 | 2: {id: 2, name: 'Mark', org_id: 2, token: '002', __optimistic_id: 'c02'}, 316 | 3: {id: 3, name: 'Sophy', org_id: 1, token: '003', __optimistic_id: 'c03'} 317 | }, 318 | by_token: { 319 | '001': {id: 1, name: 'Jane', org_id: 1, token: '001', __optimistic_id: 'c01'}, 320 | '002': {id: 2, name: 'Mark', org_id: 2, token: '002', __optimistic_id: 'c02'}, 321 | '003': {id: 3, name: 'Sophy', org_id: 1, token: '003', __optimistic_id: 'c03'} 322 | }, 323 | by_org_id: { 324 | 1: [ 325 | {id: 1, name: 'Jane', org_id: 1, token: '001', __optimistic_id: 'c01'}, 326 | {id: 3, name: 'Sophy', org_id: 1, token: '003', __optimistic_id: 'c03'} 327 | ], 328 | 2: [ 329 | {id: 2, name: 'Mark', org_id: 2, token: '002', __optimistic_id: 'c02'} 330 | ] 331 | } 332 | } 333 | */ 334 | ``` 335 | 336 | And to remove indexes at all, just pass an empty object as `indexes_map` for `syncCollections`. 337 | 338 | #### serializer 339 | 340 | By default models are stored in the tree by calling `model.toJSON` and adding 341 | an extra `__optimistic_id` which is the `model.cid`. You can serialize extra stuff by defining your own serializer function 342 | 343 | 344 | ##### Arguments 345 | 346 | `model` *(Backbone.Model)*: Model to be serialized. 347 | 348 | ##### Returns 349 | 350 | `serialized_model` *(Object)*: Plain object serialization of the model. 351 | 352 | 353 | ### API Reference 354 | 355 | #### syncCollections(collectionMap, store, [extraReducers]) 356 | 357 | Builds reducers and setups listeners in collections that dispatch actions to 358 | the store. **syncCollections** will replace existing reducers in your store, but 359 | you can still provide more reducers using the optional **extraReducers** 360 | argument. 361 | 362 | ##### Arguments 363 | `collectionMap` *(CollectionMap)*: See [collectionMap](#collection-map). 364 | 365 | `store` *(Store)*: A Redux store. 366 | 367 | [`extraReducers`] *(Object)*: Optionally specify additional reducers in an 368 | object whose values are reducer functions. These reducers will be merged and combined 369 | together with the ones defined in the collectionMap. 370 | 371 | --- 372 | 373 | #### buildReducers(collectionsMap) 374 | 375 | Creates reducers based on a 376 | [collectionMap](#collection-map), 377 | basically calling [reducerFabric](#reducer-fabric) on each defined reducer. 378 | 379 | ##### Arguments 380 | `collectionMap` *(CollectionMap)*: See [collectionMap](#collection-map). 381 | 382 | ##### Returns 383 | `reducers` *(Object)*: An object whose keys are the collection names defined in 384 | the input collectionMap, and values are generated reducer functions. 385 | 386 | --- 387 | 388 | #### buildEars(collectionsMap, store) 389 | 390 | Creates the basic action creators using [actionFabric](#action-fabric), and binds them to the 391 | appropriate Backbone.Collection events. 392 | 393 | When a collection event happens, the equivalent action will be dispatched. 394 | 395 | ##### Arguments 396 | 397 | `collectionMap` *(CollectionMap)*: See [collectionMap](#collection-map). 398 | 399 | `store` *(Store)*: A Redux store. 400 | 401 | ##### Arguments 402 | `collectionMap` *(CollectionMap)*: See [collectionMap](#collection-map). 403 | 404 | --- 405 | 406 | #### actionFabric(actionTypesMap, serializer) 407 | 408 | Returns an object of action creators functions. This functions can be hooked to 409 | Backbone collections events `add`, `remove`, `change`, and `reset`. 410 | 411 | The actions returned by this functions contain an `entities` field with the 412 | serialized models. 413 | 414 | ##### Arguments 415 | 416 | `actionTypesMap` *(Object)*: Object to map from Backbone collection event to 417 | action constant type. Keys must be `ADD`, `REMOVE`, `MERGE` ( for the change 418 | events ) and `RESET`. 419 | 420 | `serializer` *(Function)*: Model serializer function. 421 | 422 | ##### Returns 423 | 424 | `actionCreators` *(Object)*: Returns an object whose keys are `add`, `remove`, 425 | `merge` and `reset`, and values are action creator functions. 426 | 427 | --- 428 | 429 | #### reducerFabric(actionTypesMap, [indexesMap]) 430 | 431 | `actionTypesMap` *(Object)*: Object to map from Backbone collection event to 432 | action constant type. Keys must be `ADD`, `REMOVE`, `MERGE` ( for the change 433 | events ) and `RESET`. 434 | 435 | [`indexesMap`] *(Object)*: Optionally define indices passing an [indexesMap](#indexes-map). 436 | 437 | --- 438 | 439 | 440 | ### Examples 441 | 442 | * [TodoMVC](https://github.com/redbooth/backbone-redux/tree/master/examples/todomvc) 443 | 444 | ### Licence 445 | MIT 446 | -------------------------------------------------------------------------------- /examples/todomvc/README.md: -------------------------------------------------------------------------------- 1 | ### TodoMVC Example 2 | 3 | To see it in action: 4 | 1. clone the repo 5 | 2. `npm install` 6 | 3. `npm start` 7 | 4. open `localhost:3000` 8 | 9 | You will see classic todomvc app that uses Backbone collection for data handling, Backbone Router for URIs and React + Redux for UI. 10 | 11 | All data synchronisation between backbone collection and redux tree is happenning because of `backbone-redux`: see `index.js` as a starting point. 12 | -------------------------------------------------------------------------------- /examples/todomvc/containers/TodoApp/components/TodoList/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Footer extends React.Component { 4 | render() { 5 | const active = this.props.todos.filter(todo => !todo.completed).length; 6 | const completed = this.props.todos.filter(todo => todo.completed).length; 7 | 8 | return ( 9 | 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/todomvc/containers/TodoApp/components/TodoList/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | const ENTER_KEY = 13; 5 | 6 | export default class Header extends React.Component { 7 | componentDidMount(){ 8 | ReactDOM.findDOMNode(this.refs.input).focus(); 9 | } 10 | 11 | onPossibleTask({keyCode}) { 12 | if (keyCode != ENTER_KEY) { 13 | return; 14 | } 15 | 16 | const input = ReactDOM.findDOMNode(this.refs.input); 17 | const input_val = input.value.trim(); 18 | 19 | if (input_val) { 20 | this.props.onEnterPressed(input_val); 21 | input.value = ''; 22 | } 23 | } 24 | 25 | render() { 26 | return ( 27 |
28 |

todos

29 | 34 |
35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/todomvc/containers/TodoApp/components/TodoList/components/MarkAll.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Footer extends React.Component { 4 | onChange(event) { 5 | this.props.onToggleAll(event.target.checked); 6 | } 7 | 8 | render() { 9 | const active = this.props.todos.filter(todo => !todo.completed).length; 10 | 11 | return ( 12 |
13 | 19 | 20 |
21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/todomvc/containers/TodoApp/components/TodoList/components/Todo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Todo extends React.Component { 4 | onCheckboxToggle() { 5 | this.props.onCompleteToggle(this.props.todo.id); 6 | } 7 | 8 | onRemove() { 9 | this.props.onClear(this.props.todo.id); 10 | } 11 | 12 | render() { 13 | return ( 14 |
  • 15 |
    16 | 22 | 23 |
    28 |
  • 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/todomvc/containers/TodoApp/components/TodoList/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Header from './components/Header'; 3 | import Footer from './components/Footer'; 4 | import MarkAll from './components/MarkAll'; 5 | import Todo from './components/Todo'; 6 | import _ from 'lodash'; 7 | 8 | export default class TodoList extends React.Component { 9 | render() { 10 | const todos = _.sortBy(this.props.filteredTodos, 'id').map(todo => { 11 | return ( 12 | 17 | ) 18 | }); 19 | const anyTodos = !!this.props.todos.length; 20 | 21 | return ( 22 |
    23 |
    24 |
    25 | {anyTodos && } 26 |
      27 | {todos} 28 |
    29 |
    30 | {anyTodos &&
    35 | } 36 |
    37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/todomvc/containers/TodoApp/index.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import TodoList from './components/TodoList'; 3 | import _ from 'lodash'; 4 | 5 | const mapStateToProps = state => ({ 6 | todos: state.todos.entities 7 | }); 8 | 9 | const mergeProps = (stateProps, dispatchActions, ownProps) => { 10 | const filters = { 11 | all: (() => true), 12 | active: (todo => !todo.completed), 13 | completed: (todo => todo.completed) 14 | }; 15 | 16 | const filter = filters[ownProps.taskFilter] || filters.all; 17 | 18 | return Object.assign({}, stateProps, ownProps, { 19 | filteredTodos: stateProps.todos.filter(filter) 20 | }); 21 | }; 22 | 23 | export default connect(mapStateToProps, {}, mergeProps)(TodoList); 24 | -------------------------------------------------------------------------------- /examples/todomvc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | TodoMVC example 4 | 5 | 6 |
    7 |
    8 |

    todos

    9 | 10 |
    11 |
    12 | 13 | 14 |
      15 |
      16 |
      17 |
      18 | 23 | 31 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /examples/todomvc/index.js: -------------------------------------------------------------------------------- 1 | import 'todomvc-app-css/index.css'; 2 | 3 | import Backbone from 'backbone'; 4 | import $ from 'jquery'; 5 | 6 | import Todos from './legacy/todos'; 7 | import AppView from './legacy/app_view'; 8 | import Router from './legacy/router'; 9 | 10 | import { syncCollections } from 'backbone-redux'; 11 | import { createStore, compose } from 'redux'; 12 | 13 | import { devTools } from 'redux-devtools'; 14 | 15 | function createReduxStore() { 16 | const finalCreateStore = compose(devTools())(createStore); 17 | return finalCreateStore(() => {}); 18 | } 19 | 20 | $(() => { 21 | const app = {}; 22 | window.app = app; 23 | 24 | app.todos = new Todos(); 25 | app.TodoRouter = new Router(); 26 | app.appView = new AppView(); 27 | Backbone.history.start(); 28 | 29 | window.store = createReduxStore(); 30 | syncCollections({todos: app.todos}, window.store); 31 | 32 | app.appView.render(); 33 | }); 34 | -------------------------------------------------------------------------------- /examples/todomvc/legacy/app_view.jsx: -------------------------------------------------------------------------------- 1 | import Backbone from 'backbone'; 2 | import _ from 'lodash'; 3 | 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import { Provider } from 'react-redux'; 7 | import TodoApp from '../containers/TodoApp'; 8 | 9 | import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; 10 | 11 | export default Backbone.View.extend({ 12 | el: '.todoapp', 13 | 14 | initialize() { 15 | this.listenTo(app.todos, 'filter', _.debounce(this.render, 0)); 16 | app.todos.fetch({reset: true}); 17 | }, 18 | 19 | render() { 20 | const TodoAppContainer = ( 21 |
      22 | 23 | 31 | 32 | 33 | 34 | 35 |
      36 | ); 37 | 38 | this.component = ReactDOM.render( 39 | TodoAppContainer, 40 | this.el, 41 | this.onRender 42 | ); 43 | }, 44 | 45 | onClose() { 46 | ReactDOM.unmountComponentAtNode(this.el); 47 | }, 48 | 49 | onCreateTodo(title) { 50 | app.todos.create(this.newAttributes(title)); 51 | }, 52 | 53 | onCompleteToggle(id) { 54 | app.todos.get(id).toggle(); 55 | }, 56 | 57 | onClear(id) { 58 | app.todos.get(id).destroy(); 59 | }, 60 | 61 | onClearCompleted() { 62 | _.invoke(app.todos.completed(), 'destroy'); 63 | }, 64 | 65 | onToggleAll(completed) { 66 | app.todos.each(todo => todo.save({completed: completed})); 67 | }, 68 | 69 | newAttributes(title) { 70 | return { 71 | title, 72 | order: app.todos.nextOrder(), 73 | completed: false 74 | }; 75 | } 76 | }); 77 | -------------------------------------------------------------------------------- /examples/todomvc/legacy/router.js: -------------------------------------------------------------------------------- 1 | /* global app */ 2 | import Backbone from 'backbone'; 3 | 4 | export default Backbone.Router.extend({ 5 | routes: { 6 | '*filter': 'setFilter', 7 | }, 8 | 9 | setFilter: function(param) { 10 | // Set the current filter to be used 11 | app.TodoFilter = param || ''; 12 | 13 | // Trigger a collection filter event, causing hiding/unhiding 14 | // of Todo view items 15 | app.todos.trigger('filter'); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /examples/todomvc/legacy/todo.js: -------------------------------------------------------------------------------- 1 | import Backbone from 'backbone'; 2 | 3 | export default Backbone.Model.extend({ 4 | // Default attributes for the todo 5 | // and ensure that each todo created has `title` and `completed` keys. 6 | defaults: { 7 | title: '', 8 | completed: false, 9 | }, 10 | 11 | // Toggle the `completed` state of this todo item. 12 | toggle: function() { 13 | this.save({ 14 | completed: !this.get('completed'), 15 | }); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /examples/todomvc/legacy/todos.js: -------------------------------------------------------------------------------- 1 | import Backbone from 'backbone'; 2 | import LocalStorage from 'backbone.localstorage'; 3 | import Todo from './todo'; 4 | 5 | export default Backbone.Collection.extend({ 6 | // Reference to this collection's model. 7 | model: Todo, 8 | 9 | // Save all of the todo items under the `"todos"` namespace. 10 | localStorage: new LocalStorage('todos-backbone'), 11 | 12 | // Filter down the list of all todo items that are finished. 13 | completed: function() { 14 | return this.where({completed: true}); 15 | }, 16 | 17 | // Filter down the list to only todo items that are still not finished. 18 | remaining: function() { 19 | return this.where({completed: false}); 20 | }, 21 | 22 | // We keep the Todos in sequential order, despite being saved by unordered 23 | // GUID in the database. This generates the next order number for new items. 24 | nextOrder: function() { 25 | return this.length ? this.last().get('order') + 1 : 1; 26 | }, 27 | 28 | // Todos are sorted by their original insertion order. 29 | comparator: 'order', 30 | }); 31 | -------------------------------------------------------------------------------- /examples/todomvc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backbone-redux-todomvc-example", 3 | "version": "1.0.0", 4 | "description": "Backbone Collection synced through backbone-redux with redux store that drives React UI", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/redbooth/backbone-redux.git" 12 | }, 13 | "keywords": [ 14 | "backbone", 15 | "collections", 16 | "redux", 17 | "state", 18 | "functional", 19 | "immutable", 20 | "hot", 21 | "replay" 22 | ], 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/redbooth/backbone-redux/issues" 26 | }, 27 | "homepage": "https://github.com/redbooth/backbone-redux", 28 | "dependencies": { 29 | "babel-runtime": "^5.8.20", 30 | "backbone": "^1.2.2", 31 | "backbone.localstorage": "^1.1.16", 32 | "jquery": "^3.0.0", 33 | "lodash": "^3.10.1", 34 | "react": "0.14.0", 35 | "react-dom": "0.14.0", 36 | "react-redux": "^3.0.0", 37 | "redux": "^3.0.0", 38 | "todomvc-app-css": "^2.0.1" 39 | }, 40 | "devDependencies": { 41 | "babel-core": "^5.6.18", 42 | "babel-loader": "^5.1.4", 43 | "css-loader": "^0.19.0", 44 | "node-libs-browser": "^0.5.2", 45 | "react-hot-loader": "^1.2.7", 46 | "redux-devtools": "^2.1.0", 47 | "style-loader": "^0.12.3", 48 | "webpack": "^1.9.11", 49 | "webpack-dev-server": "^1.9.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/todomvc/server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | var config = require('./webpack.config'); 4 | 5 | new WebpackDevServer(webpack(config), { 6 | publicPath: config.output.publicPath, 7 | hot: true, 8 | historyApiFallback: true, 9 | stats: { 10 | colors: true 11 | } 12 | }).listen(3000, 'localhost', function (err) { 13 | if (err) { 14 | console.log(err); 15 | } 16 | 17 | console.log('Listening at localhost:3000'); 18 | }); 19 | -------------------------------------------------------------------------------- /examples/todomvc/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'eval', 6 | entry: [ 7 | 'webpack-dev-server/client?http://localhost:3000', 8 | 'webpack/hot/only-dev-server', 9 | './index' 10 | ], 11 | output: { 12 | path: path.join(__dirname, 'dist'), 13 | filename: 'bundle.js', 14 | publicPath: '/static/' 15 | }, 16 | plugins: [ 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin() 19 | ], 20 | resolve: { 21 | alias: { 22 | 'backbone-redux': path.join(__dirname, '..', '..', 'src') 23 | }, 24 | extensions: ['', '.js', '.jsx'] 25 | }, 26 | module: { 27 | loaders: [{ 28 | test: /\.jsx?$/, 29 | loaders: ['react-hot', 'babel?optional[]=runtime'], 30 | exclude: /node_modules/, 31 | include: __dirname 32 | }, { 33 | test: /\.jsx?$/, 34 | loaders: ['babel'], 35 | include: path.join(__dirname, '..', '..', 'src') 36 | }, { 37 | test: /\.css$/, 38 | loaders: ['style', 'css'] 39 | }] 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backbone-redux", 3 | "version": "0.3.0-rc.1", 4 | "description": "Easy way to keep your backbone collections and redux store in sync", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rimraf lib dist", 8 | "build": "babel src --out-dir lib", 9 | "build:umd": "webpack src/index.js dist/backbone-redux.js && NODE_ENV=production webpack src/index.js dist/backbone-redux.min.js", 10 | "lint": "eslint src test examples", 11 | "test": "NODE_ENV=test babel-tape-runner test/**/*.js | tap-dot", 12 | "ci": "npm run lint && npm run test", 13 | "prepublish": "npm run ci && npm run clean && npm run build && npm run build:umd" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/redbooth/backbone-redux.git" 18 | }, 19 | "keywords": [ 20 | "backbone", 21 | "collections", 22 | "redux", 23 | "state", 24 | "functional", 25 | "immutable", 26 | "hot", 27 | "replay" 28 | ], 29 | "author": "Ilya Zayats (http://github.com/somebody32)", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/redbooth/backbone-redux/issues" 33 | }, 34 | "homepage": "https://github.com/redbooth/backbone-redux", 35 | "devDependencies": { 36 | "async": "^2.1.4", 37 | "babel": "^5.5.8", 38 | "babel-core": "^5.6.18", 39 | "babel-eslint": "^4.1.1", 40 | "babel-loader": "^5.1.4", 41 | "babel-tape-runner": "^1.2.0", 42 | "backbone": "^1.2.3", 43 | "eslint": "^1.3", 44 | "eslint-config-airbnb": "^0.1.0", 45 | "eslint-plugin-react": "^3.3.1", 46 | "rimraf": "^2.3.4", 47 | "tap-dot": "^1.0.0", 48 | "tape": "^4.2.0", 49 | "webpack": "^1.9.6", 50 | "webpack-dev-server": "^1.8.2" 51 | }, 52 | "dependencies": { 53 | "lodash.compact": "^3.0.0", 54 | "lodash.defer": "^4.1.0", 55 | "lodash.flatten": "^3.0.2", 56 | "lodash.groupby": "^3.1.1", 57 | "redux": "^3.0.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/action-fabric.js: -------------------------------------------------------------------------------- 1 | /* 2 | This fabric creates actions object skeleton for passed constants and serializer. 3 | Useful to create all actions for ear just in one line. 4 | */ 5 | function serializeFabric(serializer, payload) { 6 | return [].concat(payload).map(serializer); 7 | } 8 | 9 | export default function({ADD, REMOVE, MERGE, RESET}, serializer) { 10 | const serialize = serializeFabric.bind(this, serializer); 11 | 12 | return { 13 | add(payload) { 14 | return { 15 | type: ADD, 16 | entities: serialize(payload), 17 | }; 18 | }, 19 | 20 | remove(payload) { 21 | return { 22 | type: REMOVE, 23 | entities: serialize(payload), 24 | }; 25 | }, 26 | 27 | merge(payload) { 28 | return { 29 | type: MERGE, 30 | entities: serialize(payload), 31 | }; 32 | }, 33 | 34 | reset(payload) { 35 | return { 36 | type: RESET, 37 | entities: serialize(payload), 38 | }; 39 | }, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/collection-tools.js: -------------------------------------------------------------------------------- 1 | import actionFabric from './action-fabric'; 2 | import reducerFabric from './reducer-fabric'; 3 | import earFabric from './ear-fabric'; 4 | 5 | import { combineReducers } from 'redux'; 6 | 7 | function buildConstants(collectionName) { 8 | const uppercasedCollectionName = collectionName.toUpperCase(); 9 | 10 | return { 11 | ADD: `ADD_${uppercasedCollectionName}`, 12 | REMOVE: `REMOVE_${uppercasedCollectionName}`, 13 | MERGE: `MERGE_${uppercasedCollectionName}`, 14 | RESET: `RESET_${uppercasedCollectionName}`, 15 | }; 16 | } 17 | 18 | function getIndex(indexesMap) { 19 | return indexesMap || {fields: {by_id: 'id'}}; 20 | } 21 | 22 | function getSerializer({serializer}) { 23 | const defaultSerializer = model => ({...model.toJSON(), __optimistic_id: model.cid}); 24 | return serializer || defaultSerializer; 25 | } 26 | 27 | function getCollection(collectionValue) { 28 | return collectionValue.collection || collectionValue; 29 | } 30 | 31 | export function buildReducers(collectionsMap) { 32 | return Object.keys(collectionsMap).reduce((collector, collectionName) => { 33 | const indexMap = getIndex(collectionsMap[collectionName].indexes_map); 34 | collector[collectionName] = reducerFabric(buildConstants(collectionName), indexMap); 35 | return collector; 36 | }, {}); 37 | } 38 | 39 | export function buildEars(collectionsMap, {dispatch}) { 40 | Object.keys(collectionsMap).forEach(collectionName => { 41 | const serializer = getSerializer(collectionsMap[collectionName]); 42 | const rawActions = actionFabric(buildConstants(collectionName), serializer); 43 | earFabric(getCollection(collectionsMap[collectionName]), rawActions, dispatch); 44 | }); 45 | } 46 | 47 | export function syncCollections(collectionsMap, store, extraReducers = {}) { 48 | const reducers = buildReducers(collectionsMap); 49 | store.replaceReducer(combineReducers({...reducers, ...extraReducers})); 50 | buildEars(collectionsMap, store); 51 | } 52 | 53 | export function syncCollection() { 54 | if (console && console.log) { 55 | console.log('backbone-redux: syncCollection is deprecated, use syncCollections instead'); 56 | } 57 | 58 | syncCollections.apply(this, arguments); 59 | } 60 | 61 | -------------------------------------------------------------------------------- /src/ear-fabric.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from 'redux'; 2 | import ModelAddBatcher from './model-add-batcher'; 3 | 4 | /** 5 | * When model have been added merge it into the big tree 6 | * 7 | * @param {Object} actions 8 | * @param {Backbone.Model} model 9 | */ 10 | function handleAdd(actions, model) { 11 | actions.add(model); 12 | } 13 | 14 | /** 15 | * When model have been changed merge it into the big tree 16 | * 17 | * @param {Object} actions 18 | * @param {Backbone.Model} model 19 | */ 20 | function handleChange(actions, model) { 21 | actions.merge(model); 22 | } 23 | 24 | /** 25 | * When model have been removed merge it into the big tree 26 | * 27 | * @param {Object} actions 28 | * @param {Backbone.Model} model 29 | */ 30 | function handleRemove(actions, model) { 31 | actions.remove(model); 32 | } 33 | 34 | /** 35 | * When collection have been reseted clear the tree and add colection's models into it 36 | * 37 | * @param {Object} actions 38 | * @param {Backbone.Collection} collection 39 | */ 40 | function handleReset(actions, collection) { 41 | actions.reset(collection.models); 42 | } 43 | 44 | /** 45 | * Imports all models on the initial load 46 | * 47 | * @param {Object} actions 48 | * @param {Backbone.Model[]} models 49 | */ 50 | function initialSync(actions, models) { 51 | actions.add(models); 52 | } 53 | 54 | /** 55 | * Binds actions and partially applies handler events to these actions 56 | * 57 | * @param {Object} rawActions 58 | * @return {Object} 59 | */ 60 | function createHandlersWithActions(rawActions, dispatch) { 61 | const actions = bindActionCreators(rawActions, dispatch); 62 | 63 | return { 64 | initialSync: initialSync.bind(this, actions), 65 | handleAdd: handleAdd.bind(this, actions), 66 | handleChange: handleChange.bind(this, actions), 67 | handleRemove: handleRemove.bind(this, actions), 68 | handleReset: handleReset.bind(this, actions), 69 | }; 70 | } 71 | 72 | /** 73 | * The ear itself 74 | * Listens on any event from the collection and updates The Big Tree 75 | * 76 | * @param {Backbone.Collection} collection 77 | * @param {Object} rawActions object with functions. They are not action creators yet. 78 | * @param {Function} dispatch 79 | */ 80 | export default function(collection, rawActions, dispatch) { 81 | const handlers = createHandlersWithActions(rawActions, dispatch); 82 | const modelAddBatcher = new ModelAddBatcher({handle: handlers.handleAdd}); 83 | 84 | handlers.initialSync(collection.models || collection); 85 | 86 | collection.on('add', (model) => modelAddBatcher.add(model)); 87 | collection.on('change', handlers.handleChange); 88 | collection.on('remove', handlers.handleRemove); 89 | collection.on('reset', handlers.handleReset); 90 | } 91 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export {syncCollection, syncCollections, buildReducers, buildEars} from './collection-tools'; 2 | export {default as actionFabric} from './action-fabric'; 3 | export {default as reducerFabric} from './reducer-fabric'; 4 | -------------------------------------------------------------------------------- /src/model-add-batcher.js: -------------------------------------------------------------------------------- 1 | import defer from 'lodash.defer'; 2 | 3 | /** 4 | * This class is responsible of batching adds, so when a fetch happens, a 5 | * single ADD action is triggered, instead of once per model. 6 | */ 7 | export default class ModelAddBatcher { 8 | constructor({ handle }) { 9 | this.models = []; 10 | this.handle = handle; 11 | } 12 | 13 | add(model) { 14 | this.flushAfter(); 15 | this.models.push(model); 16 | } 17 | 18 | flushAfter() { 19 | if (this.models.length > 0) { 20 | return; 21 | } 22 | 23 | defer(() => this.flush()); 24 | } 25 | 26 | flush() { 27 | this.handle(this.models); 28 | this.models = []; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/reducer-fabric.js: -------------------------------------------------------------------------------- 1 | import { 2 | addEntities, 3 | removeEntities, 4 | buildIndex, 5 | buildRelation, 6 | } from './reducer-tools'; 7 | 8 | function buildInitialState({fields = {}, relations = {}}) { 9 | const initIndex = (acc, index) => (acc[index] = {}, acc); 10 | 11 | return { 12 | entities: [], 13 | ...Object.keys(fields).reduce(initIndex, {}), 14 | ...Object.keys(relations).reduce(initIndex, {}), 15 | }; 16 | } 17 | 18 | function buildIndexBuilder({fields = {}, relations = {}}) { 19 | return function(entities) { 20 | const fieldBuilder = (acc, indexName) => { 21 | acc[indexName] = buildIndex(entities, fields[indexName]); 22 | return acc; 23 | }; 24 | 25 | const relationBuilder = (acc, indexName) => { 26 | acc[indexName] = buildRelation(entities, relations[indexName]); 27 | return acc; 28 | }; 29 | 30 | return { 31 | ...Object.keys(fields).reduce(fieldBuilder, {}), 32 | ...Object.keys(relations).reduce(relationBuilder, {}), 33 | }; 34 | }; 35 | } 36 | 37 | function collectIds(entity) { 38 | return [entity.id, entity.__optimistic_id]; 39 | } 40 | 41 | export default function({ADD, REMOVE, MERGE, RESET}, indexMap = {}) { 42 | const initialState = buildInitialState(indexMap); 43 | const indexBuilder = buildIndexBuilder(indexMap); 44 | 45 | return function(state = initialState, action) { 46 | let entities; 47 | let indexes; 48 | 49 | switch (action.type) { 50 | case ADD: 51 | entities = addEntities(state.entities, action.entities); 52 | indexes = indexBuilder(entities); 53 | 54 | return {...state, entities, ...indexes}; 55 | 56 | case REMOVE: 57 | const idsToRemove = action.entities.map(collectIds); 58 | 59 | entities = removeEntities(state.entities, idsToRemove); 60 | indexes = indexBuilder(entities); 61 | 62 | return {...state, entities, ...indexes}; 63 | 64 | case MERGE: 65 | const idsToReplace = action.entities.map(collectIds); 66 | 67 | entities = removeEntities(state.entities, idsToReplace); 68 | entities = addEntities(entities, action.entities); 69 | indexes = indexBuilder(entities); 70 | 71 | return {...state, entities, ...indexes}; 72 | 73 | case RESET: 74 | entities = addEntities({...initialState}.entities, action.entities); 75 | indexes = indexBuilder(entities); 76 | 77 | return {...initialState, entities, ...indexes}; 78 | 79 | default: 80 | return state; 81 | } 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/reducer-tools.js: -------------------------------------------------------------------------------- 1 | import groupBy from 'lodash.groupby'; 2 | import compact from 'lodash.compact'; 3 | import flatten from 'lodash.flatten'; 4 | 5 | export function buildIndex(entities, field) { 6 | return entities.reduce((acc, entity) => (acc[entity[field]] = entity, acc), {}); 7 | } 8 | 9 | export function buildRelation(entities, field) { 10 | return groupBy(entities, field); 11 | } 12 | 13 | export function addEntities(currentEntities, newEntities) { 14 | return [...currentEntities, ...newEntities]; 15 | } 16 | 17 | export function removeEntities(currentEntities, idsToRemove) { 18 | const ids = compact(flatten(idsToRemove)); 19 | return currentEntities.filter(entity => { 20 | return (ids.indexOf(entity.id) < 0) && (ids.indexOf(entity.__optimistic_id) < 0); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | rules: { 3 | no-shadow: 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/action-fabric-test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import actionFabric from '../src/action-fabric'; 3 | 4 | test('Building action creators', t => { 5 | const serializer = ({id}) => ({id}); 6 | const ADD = 'ADD'; 7 | const REMOVE = 'REMOVE'; 8 | const MERGE = 'MERGE'; 9 | const RESET = 'RESET'; 10 | const constants = {ADD, REMOVE, MERGE, RESET}; 11 | const actionCreator = actionFabric(constants, serializer); 12 | 13 | t.test('returns action creator that works with single entities', t => { 14 | const entity = {id: 1}; 15 | 16 | t.deepEqual(actionCreator.add(entity), {type: ADD, entities: [entity]}); 17 | t.deepEqual(actionCreator.remove(entity), {type: REMOVE, entities: [entity]}); 18 | t.deepEqual(actionCreator.merge(entity), {type: MERGE, entities: [entity]}); 19 | t.deepEqual(actionCreator.reset(entity), {type: RESET, entities: [entity]}); 20 | 21 | t.end(); 22 | }); 23 | 24 | t.test('returns action creator that works with multiple entities', t => { 25 | const entities = [{id: 1}, {id: 2}]; 26 | 27 | t.deepEqual(actionCreator.add(entities), {type: ADD, entities: [...entities]}); 28 | t.deepEqual(actionCreator.remove(entities), {type: REMOVE, entities: [...entities]}); 29 | t.deepEqual(actionCreator.merge(entities), {type: MERGE, entities: [...entities]}); 30 | t.deepEqual(actionCreator.reset(entities), {type: RESET, entities: [...entities]}); 31 | 32 | t.end(); 33 | }); 34 | 35 | t.end(); 36 | }); 37 | 38 | -------------------------------------------------------------------------------- /test/collection-tools-test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import Backbone from 'backbone'; 3 | import defer from 'lodash.defer'; 4 | import {createStore} from 'redux'; 5 | import {syncCollections} from '../src/collection-tools'; 6 | import {series} from 'async'; 7 | 8 | let collection; 9 | let store; 10 | let jane; 11 | let mark; 12 | let sophy; 13 | 14 | const processTest = t => next => defer(() => {t(); next(null);}); 15 | 16 | test('Syncing collection', t => { 17 | t.test('default values', t => { 18 | collection = new Backbone.Collection(); 19 | store = createStore(() => {}); 20 | syncCollections({people: collection}, store); 21 | 22 | // initial state 23 | t.deepEqual(store.getState(), {people: {entities: [], by_id: {}}}); 24 | 25 | // adding model 26 | jane = new Backbone.Model({id: 1, name: 'Jane'}); 27 | collection.add(jane); 28 | 29 | // adding 2 models 30 | mark = new Backbone.Model({id: 2, name: 'Mark'}); 31 | sophy = new Backbone.Model({id: 3, name: 'Sophy'}); 32 | collection.add([mark, sophy]); 33 | 34 | series([ 35 | // Batches add and handles them when the stack is cleared 36 | processTest(() => { 37 | t.deepEqual( 38 | store.getState().people, 39 | { 40 | entities: [ 41 | {id: 1, name: 'Jane', __optimistic_id: jane.cid}, 42 | {id: 2, name: 'Mark', __optimistic_id: mark.cid}, 43 | {id: 3, name: 'Sophy', __optimistic_id: sophy.cid}, 44 | ], 45 | by_id: { 46 | 1: {id: 1, name: 'Jane', __optimistic_id: jane.cid}, 47 | 2: {id: 2, name: 'Mark', __optimistic_id: mark.cid}, 48 | 3: {id: 3, name: 'Sophy', __optimistic_id: sophy.cid}, 49 | }, 50 | } 51 | ); 52 | }), 53 | processTest(() => { 54 | // changing models 55 | jane.set('name', 'Jennifer'); 56 | t.deepEqual( 57 | store.getState().people, 58 | { 59 | entities: [ 60 | {id: 2, name: 'Mark', __optimistic_id: mark.cid}, 61 | {id: 3, name: 'Sophy', __optimistic_id: sophy.cid}, 62 | {id: 1, name: 'Jennifer', __optimistic_id: jane.cid}, 63 | ], 64 | by_id: { 65 | 1: {id: 1, name: 'Jennifer', __optimistic_id: jane.cid}, 66 | 2: {id: 2, name: 'Mark', __optimistic_id: mark.cid}, 67 | 3: {id: 3, name: 'Sophy', __optimistic_id: sophy.cid}, 68 | }, 69 | } 70 | ); 71 | }), 72 | processTest(() => { 73 | // removing models 74 | collection.remove([mark, sophy]); 75 | t.deepEqual( 76 | store.getState().people, 77 | { 78 | entities: [ 79 | {id: 1, name: 'Jennifer', __optimistic_id: jane.cid}, 80 | ], 81 | by_id: { 82 | 1: {id: 1, name: 'Jennifer', __optimistic_id: jane.cid}, 83 | }, 84 | } 85 | ); 86 | }), 87 | processTest(() => { 88 | // resetting collection 89 | const barry = new Backbone.Model({id: 4, name: 'Barry'}); 90 | collection.reset([barry]); 91 | 92 | t.deepEqual( 93 | store.getState().people, 94 | { 95 | entities: [ 96 | {id: 4, name: 'Barry', __optimistic_id: barry.cid}, 97 | ], 98 | by_id: { 99 | 4: {id: 4, name: 'Barry', __optimistic_id: barry.cid}, 100 | }, 101 | } 102 | ); 103 | }), 104 | processTest(() => t.end()), 105 | ]); 106 | }); 107 | 108 | t.test('initial sync', t => { 109 | const jane = new Backbone.Model({id: 1, name: 'Jane'}); 110 | const collection = new Backbone.Collection([jane]); 111 | const store = createStore(() => {}); 112 | syncCollections({people: collection}, store); 113 | 114 | t.deepEqual( 115 | store.getState().people, 116 | { 117 | entities: [ 118 | {id: 1, name: 'Jane', __optimistic_id: jane.cid}, 119 | ], 120 | by_id: { 121 | 1: {id: 1, name: 'Jane', __optimistic_id: jane.cid}, 122 | }, 123 | } 124 | ); 125 | 126 | t.end(); 127 | }); 128 | 129 | t.test('custom indexes', t => { 130 | const jane = new Backbone.Model({id: 1, name: 'Jane', org_id: 1}); 131 | const mark = new Backbone.Model({id: 2, name: 'Mark', org_id: 2}); 132 | const sophy = new Backbone.Model({id: 3, name: 'Sophy', org_id: 1}); 133 | const collection = new Backbone.Collection([jane, mark, sophy]); 134 | const store = createStore(() => {}); 135 | 136 | const indexesMap = { 137 | relations: { 138 | by_org_id: 'org_id', 139 | }, 140 | }; 141 | 142 | syncCollections({ 143 | people: { 144 | collection: collection, 145 | indexes_map: indexesMap, 146 | }, 147 | }, store); 148 | 149 | t.deepEqual( 150 | store.getState().people, 151 | { 152 | entities: [ 153 | {id: 1, name: 'Jane', __optimistic_id: jane.cid, org_id: 1}, 154 | {id: 2, name: 'Mark', __optimistic_id: mark.cid, org_id: 2}, 155 | {id: 3, name: 'Sophy', __optimistic_id: sophy.cid, org_id: 1}, 156 | ], 157 | by_org_id: { 158 | 1: [ 159 | {id: 1, name: 'Jane', __optimistic_id: jane.cid, org_id: 1}, 160 | {id: 3, name: 'Sophy', __optimistic_id: sophy.cid, org_id: 1}, 161 | ], 162 | 2: [ 163 | {id: 2, name: 'Mark', __optimistic_id: mark.cid, org_id: 2}, 164 | ], 165 | }, 166 | } 167 | ); 168 | 169 | t.end(); 170 | }); 171 | 172 | t.test('custom serializers', t => { 173 | const jane = new Backbone.Model({id: 1, name: 'Jane'}); 174 | const serializer = (model) => ({id: model.id, name: `${model.get('name')} MeatBallovich` }); 175 | const collection = new Backbone.Collection([jane]); 176 | const store = createStore(() => {}); 177 | 178 | syncCollections({ 179 | people: { 180 | collection: collection, 181 | serializer, 182 | }, 183 | }, store); 184 | 185 | t.deepEqual( 186 | store.getState().people, 187 | { 188 | entities: [ 189 | {id: 1, name: 'Jane MeatBallovich'}, 190 | ], 191 | by_id: { 192 | 1: {id: 1, name: 'Jane MeatBallovich'}, 193 | }, 194 | } 195 | ); 196 | 197 | t.end(); 198 | }); 199 | 200 | t.test('extra reducers', t => { 201 | const collection = new Backbone.Collection(); 202 | const store = createStore(() => {}); 203 | const extraReducer = (state = {}) => state; 204 | 205 | syncCollections({people: collection}, store, {some_extra_branch: extraReducer}); 206 | 207 | t.deepEqual(store.getState(), {people: {entities: [], by_id: {}}, some_extra_branch: {}}); 208 | t.end(); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /test/reducer-fabric-test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import reducerFabric from '../src/reducer-fabric'; 3 | 4 | test('Building reducers', t => { 5 | const ADD = 'ADD'; 6 | const REMOVE = 'REMOVE'; 7 | const MERGE = 'MERGE'; 8 | const RESET = 'RESET'; 9 | const constants = {ADD, REMOVE, MERGE, RESET}; 10 | 11 | t.test('with default state ano no index map', t => { 12 | const reducer = reducerFabric(constants); 13 | 14 | t.test('with null action', t => { 15 | const action = {type: null}; 16 | t.deepEqual(reducer(undefined, action), {entities: []}); 17 | t.end(); 18 | }); 19 | 20 | t.test('with add action', t => { 21 | const entity = {id: 1}; 22 | const action = {type: ADD, entities: [entity]}; 23 | t.deepEqual(reducer(undefined, action), {entities: [entity]}); 24 | t.end(); 25 | }); 26 | 27 | t.test('with remove action', t => { 28 | const entity = {id: 1}; 29 | const action = {type: REMOVE, entities: [entity]}; 30 | t.deepEqual(reducer(undefined, action), {entities: []}); 31 | t.end(); 32 | }); 33 | 34 | t.test('with merge action', t => { 35 | const entity = {id: 1}; 36 | const action = {type: MERGE, entities: [entity]}; 37 | t.deepEqual(reducer(undefined, action), {entities: [entity]}); 38 | t.end(); 39 | }); 40 | 41 | t.test('with reset action', t => { 42 | const entity = {id: 1}; 43 | const action = {type: RESET, entities: [entity]}; 44 | t.deepEqual(reducer(undefined, action), {entities: [entity]}); 45 | t.end(); 46 | }); 47 | 48 | t.end(); 49 | }); 50 | 51 | t.test('works with custom index map', t => { 52 | const indexMap = { 53 | fields: { 54 | by_id: 'id', 55 | }, 56 | relations: { 57 | by_org_id: 'org_id', 58 | }, 59 | }; 60 | const reducer = reducerFabric(constants, indexMap); 61 | const jane = {id: 1, name: 'Jane', org_id: 1}; 62 | const sophie = {id: 2, name: 'Sophie', org_id: 2}; 63 | const mark = {id: 3, name: 'Mark', org_id: 1}; 64 | 65 | const currentState = { 66 | entities: [jane, sophie, mark], 67 | }; 68 | 69 | t.test('with null action', t => { 70 | const action = {type: null}; 71 | t.deepEqual(reducer(currentState, action), currentState); 72 | t.end(); 73 | }); 74 | 75 | t.test('with add action', t => { 76 | const jack = {id: 4, name: 'Jack', org_id: 3}; 77 | const action = {type: ADD, entities: [jack]}; 78 | t.deepEqual( 79 | reducer(currentState, action), 80 | { 81 | entities: [jane, sophie, mark, jack], 82 | by_id: { 83 | 1: jane, 84 | 2: sophie, 85 | 3: mark, 86 | 4: jack, 87 | }, 88 | by_org_id: { 89 | 1: [jane, mark], 90 | 2: [sophie], 91 | 3: [jack], 92 | }, 93 | } 94 | ); 95 | t.end(); 96 | }); 97 | 98 | t.test('with remove action', t => { 99 | const action = {type: REMOVE, entities: [sophie]}; 100 | t.deepEqual( 101 | reducer(currentState, action), 102 | { 103 | entities: [jane, mark], 104 | by_id: { 105 | 1: jane, 106 | 3: mark, 107 | }, 108 | by_org_id: { 109 | 1: [jane, mark], 110 | }, 111 | } 112 | ); 113 | t.end(); 114 | }); 115 | 116 | t.test('with merge action', t => { 117 | const newSophie = {id: 2, name: 'Sophie', org_id: 1}; 118 | const action = {type: MERGE, entities: [newSophie]}; 119 | t.deepEqual( 120 | reducer(currentState, action), 121 | { 122 | entities: [jane, mark, newSophie], 123 | by_id: { 124 | 1: jane, 125 | 2: newSophie, 126 | 3: mark, 127 | }, 128 | by_org_id: { 129 | 1: [jane, mark, newSophie], 130 | }, 131 | } 132 | ); 133 | t.end(); 134 | }); 135 | 136 | t.test('with reset action', t => { 137 | const action = {type: RESET, entities: [jane]}; 138 | t.deepEqual( 139 | reducer(currentState, action), 140 | { 141 | entities: [jane], 142 | by_id: { 143 | 1: jane, 144 | }, 145 | by_org_id: { 146 | 1: [jane], 147 | }, 148 | } 149 | ); 150 | t.end(); 151 | }); 152 | 153 | t.end(); 154 | }); 155 | 156 | t.end(); 157 | }); 158 | 159 | -------------------------------------------------------------------------------- /test/reducer-tools-test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { 3 | buildIndex, 4 | buildRelation, 5 | addEntities, 6 | removeEntities, 7 | } from '../src/reducer-tools'; 8 | 9 | test('Building index', t => { 10 | const user1 = {name: 'bob', id: 1}; 11 | const user2 = {name: 'alice', id: 2}; 12 | const user3 = {name: 'jane', id: 3}; 13 | const field = 'id'; 14 | 15 | t.test('indexes by field', t => { 16 | t.plan(1); 17 | 18 | const entities = [user1, user2, user3]; 19 | const index = buildIndex(entities, field); 20 | t.deepEqual(index, {1: user1, 2: user2, 3: user3}); 21 | }); 22 | 23 | t.test('overwrites the duplicates', t => { 24 | t.plan(1); 25 | const userWithDupId = {name: 'jane', id: 1}; 26 | const entities = [user1, user2, userWithDupId]; 27 | 28 | const index = buildIndex(entities, field); 29 | t.deepEqual(index, {1: userWithDupId, 2: user2}); 30 | }); 31 | 32 | t.end(); 33 | }); 34 | 35 | test('Building relations', t => { 36 | const user1 = {name: 'bob', id: 1, company_id: 2}; 37 | const user2 = {name: 'alice', id: 2, company_id: 1}; 38 | const user3 = {name: 'jane', id: 3, company_id: 2}; 39 | const field = 'company_id'; 40 | 41 | const entities = [user1, user2, user3]; 42 | const index = buildRelation(entities, field); 43 | 44 | t.test('builds valid relation', t => { 45 | t.plan(1); 46 | t.deepEqual(index, {1: [user2], 2: [user1, user3]}); 47 | }); 48 | 49 | t.test('saves link to initial objects', t => { 50 | t.plan(1); 51 | t.equal(index[1][0], user2); 52 | }); 53 | 54 | t.end(); 55 | }); 56 | 57 | test('Adding entities', t => { 58 | const user1 = {name: 'bob', id: 1}; 59 | const user2 = {name: 'alice', id: 2}; 60 | const user3 = {name: 'jane', id: 3}; 61 | 62 | const currentEntities = [user1]; 63 | const newEntities = [user2, user3]; 64 | 65 | t.deepEqual(addEntities(currentEntities, newEntities), [user1, user2, user3]); 66 | t.end(); 67 | }); 68 | 69 | test('Removing entities', t => { 70 | const user1 = {name: 'bob', id: 1}; 71 | const user2 = {name: 'alice', id: 2}; 72 | const user3 = {name: 'jane', id: 3}; 73 | 74 | t.test('removes objects by id', t => { 75 | t.plan(1); 76 | const currentEntities = [user2, user3]; 77 | const idsToRemove = [2]; 78 | t.deepEqual(removeEntities(currentEntities, idsToRemove), [user3]); 79 | }); 80 | 81 | t.test('removes objects by __optimistic_id', t => { 82 | t.plan(1); 83 | const optimisticUser = {name: 'not saved yet', __optimistic_id: 'c1'}; 84 | const currentEntities = [user2, user3, optimisticUser]; 85 | const idsToRemove = [2, 'c1']; 86 | 87 | t.deepEqual(removeEntities(currentEntities, idsToRemove), [user3]); 88 | }); 89 | 90 | t.test('removes even if passed array is deep and full of undefined', t => { 91 | t.plan(1); 92 | const optimisticUser = {name: 'not saved yet', __optimistic_id: 'c1'}; 93 | const currentEntities = [user1, user2, user3, optimisticUser]; 94 | const idsToRemove = [2, [1, 'c1'], undefined, null]; 95 | 96 | t.deepEqual(removeEntities(currentEntities, idsToRemove), [user3]); 97 | }); 98 | }); 99 | 100 | 101 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var webpack = require('webpack'); 4 | 5 | var plugins = [ 6 | new webpack.optimize.OccurenceOrderPlugin(), 7 | new webpack.DefinePlugin({ 8 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 9 | }) 10 | ]; 11 | 12 | if (process.env.NODE_ENV === 'production') { 13 | plugins.push( 14 | new webpack.optimize.UglifyJsPlugin({ 15 | compressor: { 16 | screw_ie8: true, 17 | warnings: false 18 | } 19 | }) 20 | ); 21 | } 22 | 23 | module.exports = { 24 | module: { 25 | loaders: [{ 26 | test: /\.js$/, 27 | loaders: ['babel-loader'], 28 | exclude: /node_modules/ 29 | }] 30 | }, 31 | output: { 32 | library: 'backbone-redux', 33 | libraryTarget: 'umd' 34 | }, 35 | plugins: plugins, 36 | resolve: { 37 | extensions: ['', '.js'] 38 | } 39 | }; 40 | --------------------------------------------------------------------------------