├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── examples ├── README.md ├── counter-vanilla │ └── index.html ├── retweet │ ├── .babelrc │ ├── README.md │ ├── app │ │ ├── routes.js │ │ ├── server.js │ │ ├── socket.js │ │ └── state.js │ ├── client │ │ ├── actions │ │ │ ├── index.js │ │ │ ├── messages.js │ │ │ ├── socket.js │ │ │ ├── tweets.js │ │ │ └── user.js │ │ ├── components │ │ │ ├── app.js │ │ │ ├── header.js │ │ │ ├── message-popup.js │ │ │ ├── message.js │ │ │ ├── messages-button.js │ │ │ ├── tweet.js │ │ │ └── tweets-list.js │ │ ├── css │ │ │ └── main.css │ │ ├── index.html │ │ ├── index.js │ │ └── state │ │ │ ├── index.js │ │ │ ├── messages.js │ │ │ ├── state.js │ │ │ ├── tweets.js │ │ │ └── user.js │ ├── package.json │ └── webpack.config.js ├── snabbdom-counter │ ├── .babelrc │ ├── README.md │ ├── client │ │ ├── index.html │ │ └── index.js │ ├── package.json │ └── webpack.config.js ├── snabbdom-observable-router │ ├── .babelrc │ ├── README.md │ ├── client │ │ ├── actions │ │ │ └── index.js │ │ ├── containers │ │ │ ├── app-container.js │ │ │ ├── home-container.js │ │ │ ├── item-container.js │ │ │ └── items-container.js │ │ ├── index.html │ │ ├── index.js │ │ ├── state │ │ │ └── index.js │ │ └── utils.js │ ├── package.json │ ├── server.js │ └── webpack.config.js └── todomvc │ ├── .babelrc │ ├── README.md │ ├── package.json │ ├── src │ ├── actions │ │ └── index.js │ ├── components │ │ ├── app.js │ │ ├── footer.js │ │ ├── header.js │ │ ├── main-section.js │ │ ├── todo-item.js │ │ └── todo-text-input.js │ ├── constants │ │ └── index.js │ ├── index.html │ ├── index.js │ └── state │ │ ├── display.js │ │ ├── editor.js │ │ ├── index.js │ │ ├── state.js │ │ └── todos.js │ └── webpack.config.js ├── lib ├── action │ └── index.js ├── errors.js ├── index.js ├── state │ ├── hooks.js │ ├── index.js │ ├── node.js │ └── tree.js └── utils.js ├── package.json ├── test ├── action │ └── index.js └── state │ ├── hooks.js │ ├── node.js │ └── tree.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | browser 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6.1" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ReduRx: State Management with Observables](https://dl.dropboxusercontent.com/u/2179993/redurx-cropped.jpg) 2 | 3 | [Travis CI Status](https://travis-ci.org/shiftyp/redurx): ![Travis CI Status Image](https://travis-ci.org/shiftyp/redurx.svg?branch=master) 4 | 5 | ## Basic Snippet 6 | Always good to start out with a basic snippet: 7 | ```JavaScript 8 | import { createState, createAction } from 'redurx'; 9 | 10 | const increment = createAction(); 11 | const decrement = createAction(); 12 | const state = createState({ 13 | counter: 0 14 | }); 15 | const counter = state('counter'); 16 | 17 | counter 18 | .asObservable() 19 | .subscribe(num => console.log(num)); 20 | 21 | state.connect(); 22 | // 0 23 | 24 | counter 25 | .reduce(increment, num => num + 1) 26 | .reduce(decrement, num => num - 1); 27 | 28 | increment(); 29 | // 1 30 | increment(); 31 | // 2 32 | decrement(); 33 | // 1 34 | decrement(); 35 | // 0 36 | ``` 37 | 38 | So what's going on here. We have some state, the `counter`, and we want to observe changes to this state from anywhere in our application. We want to change this state when events occur in our application, so we have action functions that we can call when those events happen. The way the state changes in response to those actions is defined functionally using reducers. In essence this is the [Redux](https://github.com/reactjs/redux) pattern, with two major differences. 39 | 40 | First, the state tree is implemented with [RxJS](https://github.com/Reactive-Extensions/RxJS) Observables, with every value, or node, in the tree having its own observable you can subscribe to. Second, actions creators are functions that have their own associated observable. We can hook the state observables and one or more additional observables together using `reduce`, which accepts a reducer function that works like a Redux reducer. 41 | 42 | Using a tree of observables along with action observables has many implicit features. Async operations and promises without middleware; computing additional state functionally from other parts of state; caching or collecting previous values within observables; pausing, delays, retrys, intervals, throttling, sampling, and other observable features; are all possible with ReduRx. 43 | 44 | So ReduRx is like Redux, only you can do more with less code. Redux is based on three basic principles, and ReduRx maintains these principles to allow you to write more predictable code. Given that, it makes sense to introduce the features of ReduRx using these principles as a guide: 45 | 46 | ### #1 Single Source of Truth 47 | 48 | With Redux there is single store object that stores state, and you describe the initial state of that store when it loads. You can do this either all at once when you create the store, or in parts as the reducer functions that manage the store are called. 49 | 50 | ReduRx also maintains a single state tree; only each node in the tree has an observable associated with it. You can create the state tree all at once by calling `createState` with a value that you'd like to use as the initial state. When you're ready to use the state, you call `connect` on the state; 51 | 52 | ```javascript 53 | import { createState } from 'redurx'; 54 | 55 | const state = createState({ 56 | todos: { 57 | list: [], 58 | search: { 59 | filter: '', 60 | query: '', 61 | dirty: true 62 | }, 63 | error: null 64 | }, 65 | todonts: { 66 | list: [], 67 | search: { 68 | filter: '', 69 | query: '', 70 | dirty: true 71 | }, 72 | error: null 73 | }, 74 | }); 75 | 76 | state.connect(); 77 | ``` 78 | Each node is a function that can be used to access any child node by passing a dot delimited path to that node. So to get the node for the todo list from the state you could do: 79 | ```javascript 80 | state('todos.list'); 81 | // or 82 | state('todos')('list'); 83 | ``` 84 | Each node has an observable associated with it that you can subscribe to. To get an observable call `asObservable` on the node. 85 | ```javascript 86 | state('todos.list') 87 | .asObservable() 88 | .subscribe(list => { 89 | console.log(`The list: ${JSON.stringify(list)}`); 90 | }); 91 | // The List: [] 92 | ``` 93 | You don't have to define your initial state at the outset however. You can figure out what the state for any part of your tree is at any point in the future. You can set the initial state by calling `setInitialState` on the node, or by passing the initial state as a second argument when accessing the node: 94 | ```javascript 95 | import { createState } from 'redurx'; 96 | 97 | const state = createState(); 98 | 99 | state.setInitialState({ 100 | todos: { 101 | list: [], 102 | search: { 103 | filter: '', 104 | query: '', 105 | dirty: true 106 | }, 107 | error: null 108 | } 109 | }); 110 | 111 | setTimeout(() => { 112 | state('todonts').setInitialState({ 113 | search: { 114 | filter: '', 115 | query: '', 116 | dirty: true 117 | }, 118 | error: null 119 | }); 120 | 121 | state('todonts.list', []); 122 | 123 | state.connect(); 124 | }, 1000); 125 | ``` 126 | You can subscribe to changes on a node at any point though, even before the node has an initial value. If you've already connected the parent state before defining a new node, make sure to connect the child node after referencing it. 127 | ```javascript 128 | import { createState } from 'redurx'; 129 | 130 | const state = createState(); 131 | 132 | state('todonts.list') 133 | .asObservable() 134 | .subscribe(list => { 135 | console.log(`The list: ${JSON.stringify(list)}`); 136 | }); 137 | 138 | state.connect(); 139 | 140 | setTimeout(() => { 141 | state('todonts').setInitialState({ 142 | list: [], 143 | search: { 144 | filter: '', 145 | query: '', 146 | dirty: true 147 | }, 148 | error: null 149 | }).connect(); 150 | }, 1000); 151 | 152 | // ...time passes 153 | // The List: [] 154 | ``` 155 | ### #2: State is Read only 156 | 157 | Observables give you values, not the other way around. 158 | 159 | ### #3: Changes are made with Pure Functions 160 | 161 | So if we're subscribing to changes in state, then state must be changeable. This is where ReduRx is like Redux, in that you can write reducer functions that take the previous value for a node, and some data, and return a new value for the node. You provide this additional data as a set of observables, and you provide your reducer functions through the node's `reduce` api: 162 | ```javascript 163 | import Rx from 'rx'; 164 | // We defined and connected state somewhere else 165 | import state from '../state'; 166 | 167 | const itemAction = new Rx.Subject(); 168 | const errorAction = new Rx.Subject(); 169 | 170 | const todoState = state('todos'); 171 | const listState = todoState('list'); 172 | const errorState = todoState('error'); 173 | 174 | 175 | const logStateWithType = (someState, type) => { 176 | someState 177 | .asObservable() 178 | .subscribe(list => { 179 | console.log(`The ${type}: ${JSON.stringify(list)}`); 180 | }); 181 | }; 182 | 183 | logStateWithType(listState, 'List') 184 | // The List: [] 185 | logStateWithType(errorState, 'Error') 186 | // The Error: null 187 | 188 | todoState 189 | .reduce(itemAction, (state, item) => { 190 | return Object.assign({}, state, { 191 | list: [...state.list, item] 192 | }); 193 | }) 194 | .reduce(errorAction, (state, err) => { 195 | return Object.assign({}, state, { 196 | list: [], 197 | error: item.message 198 | }); 199 | }); 200 | 201 | itemAction.onNext(42); 202 | // The List: [42] 203 | itemAction.onNext(50); 204 | // The List: [42, 50] 205 | errorAction.onNext(new Error('AHHHH!')); 206 | // The List: [] 207 | // The Error: 'AHHHH!' 208 | ``` 209 | Notice that the reducers are returning state for the parent node, but these changes are being propagated to the child nodes. The reverse is also true, in that if you hook reducers into child nodes it will update the state for the parent node. In this case, all updates to child nodes that result from an identical hooked observable will only cause a single update on all parent nodes. 210 | 211 | To make these "action creator" observables easier to manage, ReduRx also includes a `createAction` function, that creates an update function with an associated observable. The create action function takes a callback that allows you to configure the action's observable, allowing you to transform the arguments to the action. Here's a somewhat complete example of what the state and business logic for a todo list app might look like: 212 | ```javascript 213 | import axios from 'axios'; 214 | import { createAction } from 'redurx'; 215 | // We defined and connected state somewhere else 216 | import state from '../state'; 217 | 218 | const todoState = state('todos').setInitialState({ 219 | list: [], 220 | search: { 221 | filter: '', 222 | query: '', 223 | dirty: true 224 | }, 225 | error: null 226 | }); 227 | 228 | export const setTodoFilter = createAction((filters) => { 229 | return filters.distinctUntilChanged(); 230 | }); 231 | 232 | export const setTodoQuery = createAction((queries) => { 233 | return queries.distinctUntilChanged(); 234 | }); 235 | 236 | export const getTodos = createAction((submits) => { 237 | return submits 238 | .withLatestFrom( 239 | todoState('search.dirty').asObservable(), 240 | (_, dirty) => dirty 241 | ) 242 | .filter(dirty => dirty) 243 | .withLatestFrom( 244 | todoState('search.filter').asObservable(), 245 | todoState('search.query').asObservable(), 246 | (_, filter, query) => ({ filter, query }) 247 | ) 248 | .flatMapLatest(params => axios.get('/api/todos', params) 249 | .then(result => result.data) 250 | .catch(err => { 251 | getTodosError(err); 252 | return []; 253 | })); 254 | }); 255 | 256 | export const getTodosError = createAction(); 257 | 258 | todoState('search.filter') 259 | .reduce(setTodoFilter, (state, filter) => filter); 260 | 261 | todoState('search.query') 262 | .reduce(setTodoQuery, (state, query) => query); 263 | 264 | todoState('search.dirty') 265 | .reduce([setTodoFilter, setTodoQuery], () => true) 266 | .reduce(getTodos, () => false) 267 | 268 | todoState('list') 269 | .reduce(getTodos, (state, list) => list); 270 | 271 | todoState('error') 272 | .reduce(getTodos, () => null) 273 | .reduce(getTodosError, (err) => err); 274 | ``` 275 | Because you can reduce values from any observable, you can even use other parts of the state to create computed properties. It's possible to create an infinite loop this way, so make sure that you don't hook state into its own children. 276 | ```javascript 277 | import { createState } from 'redurx'; 278 | 279 | const state = createState({ 280 | todos: { 281 | list: [{ 282 | text: 'Some Todo', 283 | completed: false 284 | },{ 285 | text: 'Some Other Todo', 286 | completed: true 287 | }], 288 | filteredList: [], 289 | filter: true 290 | } 291 | }); 292 | 293 | state('todos.filteredList').reduce( 294 | [ 295 | state('todos.list').asObservable(), 296 | state('todos.filter').asObservable() 297 | ], 298 | (filtered, [list, filter]) => ( 299 | list.filter(todo => todo.completed === filter) 300 | ) 301 | ); 302 | 303 | // Call after you've hooked the state into itself 304 | state.connect(); 305 | 306 | state('todos') 307 | .asObservable() 308 | .subscribe(todos => { 309 | console.log(`The Filtered List: ${JSON.stringify(todos.filteredList)}`) 310 | }); 311 | // The Filtered List: [{text:'Some Other Todo',completed:true}] 312 | ``` 313 | 314 | ## One Way Data Flow 315 | 316 | ReduRx maintains one way data flow through your application just like any Flux framework. Data is aggregated from various sources using observables, and given some input you'll get predictable output. [FRP](https://en.wikipedia.org/wiki/Functional_reactive_programming) FTW! Here's how it works with ReduRx where the state is an object, with two values `bar` and `foo`. A single action creator is hooked into the base object, and we're subscribing to changes on the base object as well. The flow goes from green to blue to red: 317 | 318 | ![Diagram of one way data flow](https://dl.dropboxusercontent.com/u/2179993/redurx-data-flow.svg) 319 | 320 | 321 | ## How would I use this? 322 | ReduRx, like Redux, can be used anywhere you'd like some functional state management. ReduRx is probably useful under a wider set of circumstances because you can subscribe to state changes anywhere in the tree, not just at the root. The obvious use for it is as a state container for React; and using something like [recompose](https://github.com/acdlite/recompose)'s [observable utilities](https://github.com/acdlite/recompose/blob/master/docs/API.md#observable-utilities) this turns out to be pretty simple: 323 | ```javascript 324 | // See setting the observable config in the recompose docs 325 | // You'll want to set it for RxJS 4 326 | import { mapPropsStream } from 'recompose'; 327 | 328 | import state from '../state'; 329 | import { 330 | setTodoFilter, 331 | setTodoQuery, 332 | getTodos 333 | } from '../actions/todos'; 334 | import TodoSearchBar from './todo-search-bar'; 335 | 336 | const enhance = mapPropsStream(propsStream => { 337 | return propsStream 338 | .combineLatest( 339 | state('todos').asObservable(), 340 | (props, { list, search }) => ({ 341 | ...props, 342 | list, 343 | search 344 | }) 345 | ); 346 | }) 347 | 348 | const TodoList = enhance(({ list, search }) => { 349 | const searchActions = { setTodoQuery, setTodoFilter, getTodos }; 350 | return ( 351 |
352 | 353 | 356 |
357 | ); 358 | }) 359 | ``` 360 | 361 | Pretty cool right! This project is still in it's early stages (read alpha), but that's how every project we couldn't live without got started. However, the code has been tested in IE10 and Safari 5.1 (Windows), as well as all modern browsers. Bug reports and contributions are welcome. 362 | 363 | # Changelog 364 | 365 | ### v0.4.0 366 | 367 | - Feature [#18](https://github.com/shiftyp/redurx/issues/18): Added ability to create composite nodes. 368 | - Feature [#17](https://github.com/shiftyp/redurx/issues/17): Chages to state shape are now allowed. 369 | 370 | ### v0.3.3 371 | 372 | - Removed duplicative WeakMap shim 373 | 374 | ### v0.3.2 375 | 376 | - Feature: Added shims for expanded browser support. 377 | - Bug: Fixed bug in ReTweet example webpack config. 378 | 379 | ### v0.3.1 380 | 381 | - Bugfix: Action observables are now shared, eliminating duplicate side effects Bug 382 | - Bugfix: State can now be set on leaf nodes created provisionally as children of a tree node 383 | 384 | ### v0.3.0 385 | 386 | - Feature: Breaking changes to the reducer api. `hookReducers` with its `next` and `error` reducers have been replaced by a single `reduce` function. This takes a single observable, or an array of observables; along with a reducer function. Silent errors are gone; and *only you can prevent uncaught errors in your observables*. 387 | 388 | ![Smokey the Bear](https://dl.dropboxusercontent.com/u/2179993/Smokey-the-bear-2.jpg) 389 | 390 | - Feature: Tested and documented previously available feature for setting a nodes initial state using the accessor function. 391 | - Feature: Added an error when a reducer returns undefined. 392 | - Feature: Improved unit tests, which cover errors and other new functionality. 393 | 394 | ## License 395 | 396 | ISC License 397 | 398 | Copyright (c) 2016, Ryan Lynch 399 | 400 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 401 | 402 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 403 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | To view the examples, start a server anywhere (if you have python for example you could run `python -m SimpleHTTPServer`), and load the examples. The TodoMVC example needs a build step. See the README in that directory for details. 2 | -------------------------------------------------------------------------------- /examples/counter-vanilla/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ReduRx basic example 5 | 6 | 7 | 8 |
9 |

10 | Clicked: times 11 | 12 | 13 | 14 | 15 |

16 |
17 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /examples/retweet/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/retweet/README.md: -------------------------------------------------------------------------------- 1 | To build and run server: 2 | ``` 3 | npm install 4 | npm run build 5 | npm start 6 | ``` 7 | -------------------------------------------------------------------------------- /examples/retweet/app/routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | const router = Router(); 3 | 4 | const state = require('./state'); 5 | 6 | router.get('/api/messages/:id', (req, res) => { 7 | const messages = state.getMessagesFor(req.params.id); 8 | res.json(messages || []); 9 | }); 10 | 11 | module.exports = router; 12 | -------------------------------------------------------------------------------- /examples/retweet/app/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const server = require('http').Server(app); 4 | const io = require('socket.io')(server); 5 | const path = require('path'); 6 | 7 | const socket = require('./socket'); 8 | const routes = require('./routes'); 9 | 10 | const PORT = process.env.PORT || 3000; 11 | 12 | app.use(express.static(path.join(__dirname, '../dist'))); 13 | app.use(routes); 14 | 15 | socket(io); 16 | 17 | server.listen(PORT, () => { 18 | console.log(`\uD83C\uDF0E App is listening on port ${PORT}`); 19 | }); 20 | -------------------------------------------------------------------------------- /examples/retweet/app/socket.js: -------------------------------------------------------------------------------- 1 | const state = require('./state'); 2 | 3 | module.exports = (io) => { 4 | const tweetObs = state.initializeTweetObservable(); 5 | let nextUserId = 0; 6 | 7 | tweetObs.subscribe(tweet => { 8 | io.emit('tweets', [tweet]); 9 | }); 10 | 11 | io.on('connect', socket => { 12 | const userId = nextUserId++; 13 | const messageObs = state.initializeMessagesFor(userId); 14 | const messageSubscription = messageObs.subscribe(count => { 15 | socket.emit('message', count); 16 | }); 17 | 18 | socket.on('disconnect', () => { 19 | state.deinitializeMessagesFor(userId); 20 | messageSubscription.dispose(); 21 | console.log(`\uD83D\uDC64 User ${userId} disconnected`); 22 | }); 23 | 24 | socket.emit('registered', userId); 25 | socket.emit('tweets', state.getAllTweets()); 26 | 27 | console.log(`\uD83D\uDC64 User ${userId} connected`); 28 | }) 29 | console.log('\uD83D\uDD0C Socket connected') 30 | } 31 | -------------------------------------------------------------------------------- /examples/retweet/app/state.js: -------------------------------------------------------------------------------- 1 | const Rx = require('rx'); 2 | const faker = require('faker'); 3 | 4 | const userMessages = {}; 5 | let tweets = []; 6 | 7 | const randomInterval = (min, max) => { 8 | return Math.floor(Math.random() * (1 + max - min)) + min; 9 | }; 10 | 11 | const createRandomObservable = (min, max) => { 12 | return Rx.Observable.range(1, 4) 13 | .flatMap(() => { 14 | return Rx.Observable 15 | .interval(randomInterval(min, max)) 16 | }) 17 | }; 18 | 19 | const createMessageObservable = (min, max) => { 20 | return createRandomObservable(min, max) 21 | .map((x) => { 22 | const tweet = { 23 | id: (Math.random() * 100000).toFixed(0), 24 | name: faker.name.findName(), 25 | avatar: faker.image.avatar(), 26 | text: faker.lorem.sentence() 27 | }; 28 | return tweet; 29 | }) 30 | }; 31 | 32 | const initializeTweetObservable = () => { 33 | const tweetObs = createMessageObservable(3000, 15000); 34 | tweetObs.subscribe(tweet => { 35 | tweets = [tweet, ...tweets.slice(0, 11)] 36 | }); 37 | return tweetObs; 38 | }; 39 | 40 | const initializeMessagesFor = (userId) => { 41 | userMessages[userId] = { 42 | list: [] 43 | }; 44 | return createMessageObservable(10000, 20000) 45 | .doOnNext(message => { 46 | const messages = userMessages[userId]; 47 | const { list } = messages; 48 | userMessages[userId] = Object.assign({}, messages, { 49 | list: [message, ...list.slice(0, 11)] 50 | }) 51 | }) 52 | .map(() => userMessages[userId].list.length); 53 | }; 54 | 55 | const getMessagesFor = (userId) => { 56 | const list = userMessages[userId].list; 57 | userMessages[userId].list = []; 58 | return list; 59 | }; 60 | 61 | const deinitializeMessagesFor = (userId) => { 62 | delete userMessages[userId]; 63 | }; 64 | 65 | const getAllTweets = () => { 66 | return tweets.slice(); 67 | }; 68 | 69 | module.exports = { 70 | initializeTweetObservable, 71 | initializeMessagesFor, 72 | deinitializeMessagesFor, 73 | getMessagesFor, 74 | getAllTweets 75 | }; 76 | -------------------------------------------------------------------------------- /examples/retweet/client/actions/index.js: -------------------------------------------------------------------------------- 1 | export * from './socket'; 2 | export * from './tweets'; 3 | export * from './user'; 4 | export * from './messages'; 5 | -------------------------------------------------------------------------------- /examples/retweet/client/actions/messages.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { createAction } from 'redurx'; 3 | 4 | import state from '../state/state'; 5 | import { initializeSocket } from './socket'; 6 | 7 | export const messageRecieved = createAction(); 8 | export const showMessages = createAction(); 9 | export const hideMessages = createAction(); 10 | 11 | export const retrieveMessages = createAction(req => { 12 | return req 13 | .withLatestFrom(state('messages.totalUnread').asObservable(), (_, total) => total) 14 | .filter(total => total > 0) 15 | .withLatestFrom(state('user.id').asObservable(), (_, id) => id) 16 | .flatMapLatest((userId) => { 17 | return axios.get(`/api/messages/${userId}`) 18 | .then(res => res.data) 19 | .catch(err => { 20 | console.log(err); 21 | return []; 22 | }); 23 | }) 24 | .doOnNext(() => showMessages()); 25 | }); 26 | 27 | initializeSocket.asObservable().subscribe(io => { 28 | io.on('message', messageRecieved); 29 | }); 30 | -------------------------------------------------------------------------------- /examples/retweet/client/actions/socket.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client'; 2 | import { createAction } from 'redurx'; 3 | 4 | export const initializeSocket = createAction(init => { 5 | return init 6 | .take(1) 7 | .map(() => io()); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/retweet/client/actions/tweets.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redurx'; 2 | import { initializeSocket } from './socket'; 3 | 4 | export const tweetsRecieved = createAction(); 5 | 6 | initializeSocket.asObservable().subscribe(io => { 7 | io.on('tweets', tweetsRecieved); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/retweet/client/actions/user.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redurx'; 2 | import { initializeSocket } from './socket'; 3 | 4 | export const registered = createAction(); 5 | 6 | initializeSocket.asObservable().subscribe(io => { 7 | io.on('registered', registered); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/retweet/client/components/app.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Header from './header'; 4 | import TweetsList from './tweets-list'; 5 | 6 | const App = ({ tweets, messages, retrieveMessages, hideMessages }) => { 7 | return ( 8 |
9 |
15 | 16 |
17 | ) 18 | }; 19 | 20 | App.propTypes = { 21 | tweets: PropTypes.object.isRequired, 22 | messages: PropTypes.object.isRequired, 23 | retrieveMessages: PropTypes.func.isRequired, 24 | hideMessages: PropTypes.func.isRequired 25 | }; 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /examples/retweet/client/components/header.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import MessagesButton from './messages-button' 4 | 5 | const Header = ({ tweets, retrieveMessages, hideMessages, messages }) => { 6 | return ( 7 |
8 |
9 |

10 | ReTweet 11 |

12 |
13 |
14 | 18 |

19 | Total Tweets Seen: {tweets.total} 20 |

21 |
22 |
23 | ); 24 | } 25 | 26 | Header.propTypes = { 27 | tweets: PropTypes.object.isRequired, 28 | messages: PropTypes.object.isRequired, 29 | retrieveMessages: PropTypes.func.isRequired, 30 | hideMessages: PropTypes.func.isRequired 31 | }; 32 | 33 | export default Header; 34 | -------------------------------------------------------------------------------- /examples/retweet/client/components/message-popup.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Message from './message'; 4 | 5 | const hideHandler = hideMessages => e => { 6 | e.stopPropagation(); 7 | hideMessages(); 8 | }; 9 | 10 | const MessagePopup = ({ list, shown, totalUnread, retrieveMessages, hideMessages }) => { 11 | if (shown) { 12 | const showMore = totalUnread > 0 ? ( 13 | Click To Show Unread Messages 14 | ) : null; 15 | return ( 16 | 29 | ); 30 | } else { 31 | return null; 32 | } 33 | }; 34 | 35 | MessagePopup.propTypes = { 36 | list: PropTypes.array.isRequired, 37 | shown: PropTypes.bool.isRequired, 38 | totalUnread: PropTypes.number.isRequired, 39 | retrieveMessages: PropTypes.func.isRequired, 40 | hideMessages: PropTypes.func.isRequired 41 | }; 42 | 43 | export default MessagePopup; 44 | -------------------------------------------------------------------------------- /examples/retweet/client/components/message.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const Message = ({ message }) => { 4 | return ( 5 |
  • 6 | 7 | {message.name} 8 | {message.text} 9 |
  • 10 | ) 11 | }; 12 | 13 | Message.propTypes = { 14 | message: PropTypes.object.isRequired 15 | }; 16 | 17 | export default Message; 18 | -------------------------------------------------------------------------------- /examples/retweet/client/components/messages-button.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import MessagePopup from './message-popup'; 4 | 5 | const MessagesButton = ({ retrieveMessages, hideMessages, messages }) => { 6 | return ( 7 | 18 | ) 19 | }; 20 | 21 | MessagesButton.propTypes = { 22 | retrieveMessages: PropTypes.func.isRequired, 23 | hideMessages: PropTypes.func.isRequired, 24 | messages: PropTypes.object.isRequired 25 | }; 26 | 27 | export default MessagesButton; 28 | -------------------------------------------------------------------------------- /examples/retweet/client/components/tweet.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const Tweet = ({ avatar, name, text, id }) => { 4 | return ( 5 |
    6 |
    7 | 8 |
    9 |
    10 |

    {name}

    11 |

    {text}

    12 |
    13 |
    14 | ); 15 | }; 16 | 17 | Tweet.propTypes = { 18 | avatar: PropTypes.string.isRequired, 19 | name: PropTypes.string.isRequired, 20 | text: PropTypes.string.isRequired, 21 | id: PropTypes.string.isRequired 22 | }; 23 | 24 | export default Tweet; 25 | -------------------------------------------------------------------------------- /examples/retweet/client/components/tweets-list.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Tweet from './tweet'; 4 | 5 | const TweetsList = ({ tweets }) => { 6 | return ( 7 |
    8 | {tweets.list.map((tweet, i) => )} 9 |
    10 | ) 11 | }; 12 | 13 | TweetsList.propTypes = { 14 | tweets: PropTypes.object.isRequired 15 | }; 16 | 17 | export default TweetsList; 18 | -------------------------------------------------------------------------------- /examples/retweet/client/css/main.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | max-width: 1000px; 4 | margin: 0 auto; 5 | } 6 | 7 | .blue { 8 | color: #0078e7; 9 | display: inline-block; 10 | padding: 5px; 11 | border: 5px solid #222; 12 | border-right: none; 13 | } 14 | 15 | .grey { 16 | background: #222; 17 | color: #fff; 18 | display: inline-block; 19 | padding: 10px; 20 | border-left: 5px solid #0078e7; 21 | } 22 | 23 | header { 24 | padding-bottom: 20px; 25 | padding-top: 20px; 26 | border-top: 5px solid #777; 27 | margin-bottom: 20px; 28 | } 29 | 30 | header h1 { 31 | /*font-size: 4em;*/ 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | .total-tweets { 37 | float: right; 38 | margin: 15px 10px 0 0; 39 | } 40 | 41 | .messages-button { 42 | margin-top: 11px; 43 | position: relative; 44 | float: right; 45 | } 46 | 47 | .show-more { 48 | text-align: center; 49 | } 50 | 51 | .show-more .fa-remove { 52 | float: right; 53 | } 54 | 55 | .message-popup { 56 | list-style: none; 57 | text-align: left; 58 | position: absolute; 59 | bottom: -20px;; 60 | left: 50%; 61 | transform: translateY(100%) translateX(-50%); 62 | background: #222; 63 | color: #fff; 64 | width: 400px; 65 | margin: 0; 66 | padding: 10px; 67 | border-radius: 10px; 68 | } 69 | 70 | .message-popup:before { 71 | display: block; 72 | content: ' '; 73 | position: absolute; 74 | top: -20px; 75 | left: 50%; 76 | transform: translateX(-15px); 77 | width: 0; 78 | height: 0; 79 | border-style: solid; 80 | border-width: 0 15px 20px 15px; 81 | border-color: transparent transparent #222 transparent; 82 | } 83 | 84 | .message { 85 | height: 30px; 86 | line-height: 20px; 87 | text-overflow: ellipsis; 88 | overflow: hidden; 89 | } 90 | 91 | .message-avatar { 92 | height: 30px; 93 | margin-right: 10px; 94 | } 95 | -------------------------------------------------------------------------------- /examples/retweet/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ReTweet 11 | 12 | 13 |
    14 |
    15 |
    16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/retweet/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import setObservableConfig from 'recompose/setObservableConfig'; 4 | import mapPropsStream from 'recompose/mapPropsStream'; 5 | import rxjs4config from 'recompose/rxjs4ObservableConfig'; 6 | 7 | setObservableConfig(rxjs4config); 8 | 9 | import state from './state'; 10 | import { 11 | initializeSocket, 12 | retrieveMessages, 13 | hideMessages 14 | } from './actions'; 15 | import App from './components/app'; 16 | import './css/main.css'; 17 | 18 | const enhance = mapPropsStream(propsStream => { 19 | return propsStream 20 | .combineLatest( 21 | state.asObservable(), 22 | (props, { tweets, messages }) => Object.assign({}, props, { 23 | tweets, 24 | messages 25 | }) 26 | ); 27 | }); 28 | 29 | const EnhancedApp = enhance(App); 30 | 31 | initializeSocket(); 32 | 33 | render( 34 | , 38 | document.getElementById('root') 39 | ); 40 | -------------------------------------------------------------------------------- /examples/retweet/client/state/index.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import './tweets'; 3 | import './messages'; 4 | import './user'; 5 | 6 | state.connect(); 7 | 8 | export default state; 9 | -------------------------------------------------------------------------------- /examples/retweet/client/state/messages.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import { 3 | messageRecieved, 4 | retrieveMessages, 5 | showMessages, 6 | hideMessages 7 | } from '../actions'; 8 | 9 | const messages = state('messages', { 10 | shown: false, 11 | list: [], 12 | totalUnread: 0 13 | }); 14 | 15 | messages('totalUnread') 16 | .reduce(messageRecieved, (state, total) => total) 17 | .reduce(retrieveMessages, (state, list) => state - list.length); 18 | 19 | messages('list') 20 | .reduce(retrieveMessages, (state, list) => [...list, ...state]) 21 | .reduce(hideMessages, () => []); 22 | 23 | messages('shown') 24 | .reduce(showMessages, () => true) 25 | .reduce(hideMessages, () => false); 26 | -------------------------------------------------------------------------------- /examples/retweet/client/state/state.js: -------------------------------------------------------------------------------- 1 | import { createState } from 'redurx'; 2 | 3 | export default createState(); 4 | -------------------------------------------------------------------------------- /examples/retweet/client/state/tweets.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import { tweetsRecieved } from '../actions'; 3 | 4 | const tweets = state('tweets', { 5 | list: [], 6 | total: 0 7 | }); 8 | 9 | tweets('list') 10 | .reduce(tweetsRecieved, (state, tweets) => ( 11 | [...tweets, ...state.slice(0, 11)] 12 | )); 13 | 14 | tweets('total') 15 | .reduce(tweetsRecieved, (state, tweets) => state + tweets.length); 16 | -------------------------------------------------------------------------------- /examples/retweet/client/state/user.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import { registered } from '../actions'; 3 | 4 | const user = state('user'); 5 | 6 | state('user', { 7 | id: 0 8 | }); 9 | 10 | user('id').reduce(registered, (state, id) => id); 11 | -------------------------------------------------------------------------------- /examples/retweet/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retweet", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "A fake twitter example using React and ReduRx", 6 | "main": "index.js", 7 | "scripts": { 8 | "clean": "rm -rf dist", 9 | "cp": "cp client/index.html dist", 10 | "build:js": "webpack", 11 | "build": "npm run clean && npm run build:js && npm run cp", 12 | "start": "node app/server.js" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "axios": "0.12.0", 18 | "babel-core": "^6.10.4", 19 | "babel-loader": "^6.2.4", 20 | "babel-preset-es2015": "^6.9.0", 21 | "babel-preset-react": "^6.11.1", 22 | "express": "^4.14.0", 23 | "faker": "^3.1.0", 24 | "font-awesome": "4.6.3", 25 | "json-loader": "^0.5.4", 26 | "raw-loader": "^0.5.1", 27 | "react": "^15.2.0", 28 | "react-dom": "^15.2.0", 29 | "react-markdown": "^2.3.0", 30 | "recompose": "^0.20.2", 31 | "redurx": "^0.4.0", 32 | "socket.io": "^1.4.8", 33 | "style-loader": "^0.13.1", 34 | "webpack": "^1.13.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/retweet/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | devtool: 'source-map', 5 | entry: [ 6 | './client/index.js' 7 | ], 8 | output: { 9 | path: './dist/js', 10 | filename: 'bundle.js', 11 | publicPath: '/js/' 12 | }, 13 | plugins: [ 14 | new webpack.optimize.OccurrenceOrderPlugin() 15 | ], 16 | module: { 17 | loaders: [ 18 | { 19 | test: /\.js$/, 20 | loaders: [ 'babel' ], 21 | exclude: /node_modules/ 22 | }, 23 | { 24 | test: /\.css?$/, 25 | loaders: [ 'style', 'raw' ] 26 | }, 27 | { 28 | test: /\.json$/, 29 | loader: 'json' 30 | } 31 | ] 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /examples/snabbdom-counter/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["syntax-jsx", "babel-snabbdom-jsx"], 3 | "presets": ["es2015"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/snabbdom-counter/README.md: -------------------------------------------------------------------------------- 1 | To build: 2 | ``` 3 | npm install 4 | npm run build 5 | ``` 6 | -------------------------------------------------------------------------------- /examples/snabbdom-counter/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Snabbdom ReduRx Counter 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/snabbdom-counter/client/index.js: -------------------------------------------------------------------------------- 1 | import snabbdom from 'snabbdom'; 2 | import eventlisteners from 'snabbdom/modules/eventlisteners'; 3 | import { createState, createAction } from 'redurx'; 4 | import h from 'snabbdom/h'; 5 | 6 | const patch = snabbdom.init([eventlisteners]); 7 | 8 | const state = createstate({ counter: 0 }); 9 | 10 | const increment = createAction(); 11 | const decrement = createAction(); 12 | 13 | state('counter') 14 | .reduce(increment, num => num + 1) 15 | .reduce(decrement, num => num - 1); 16 | 17 | const render = (counter) => ( 18 |
    19 |
    {counter}
    20 | 21 | 22 |
    23 | ); 24 | 25 | const vdom = state('counter') 26 | .asObservable() 27 | .map(render) 28 | .scan(patch, document.getElementById('root')); 29 | 30 | vdom.subscribeOnError(err => console.error('Rendering Error:', err.stack)); 31 | 32 | state.connect(); 33 | -------------------------------------------------------------------------------- /examples/snabbdom-counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snabbdrx", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "An experiment with snabbdom and redurx", 6 | "main": "index.js", 7 | "scripts": { 8 | "clean": "rm -rf dist", 9 | "cp": "cp client/index.html dist", 10 | "build:js": "webpack", 11 | "build": "npm run clean && npm run build:js && npm run cp" 12 | }, 13 | "author": "Ryan Lynch (github.com/shiftyp)", 14 | "license": "ISC", 15 | "dependencies": { 16 | "babel-core": "^6.10.4", 17 | "babel-loader": "^6.2.4", 18 | "babel-plugin-syntax-jsx": "6.8.0", 19 | "babel-preset-es2015": "^6.9.0", 20 | "babel-snabbdom-jsx": "0.3.0", 21 | "raw-loader": "^0.5.1", 22 | "redurx": "^0.4.0", 23 | "rx": "4.1.0", 24 | "snabbdom": "0.5.0", 25 | "snabbdom-jsx": "0.3.0", 26 | "style-loader": "^0.13.1", 27 | "webpack": "^1.13.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/snabbdom-counter/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | devtool: 'source-map', 5 | entry: [ 6 | './client/index.js' 7 | ], 8 | output: { 9 | path: './dist/js', 10 | filename: 'bundle.js', 11 | publicPath: '/js/' 12 | }, 13 | plugins: [ 14 | new webpack.optimize.OccurrenceOrderPlugin() 15 | ], 16 | module: { 17 | loaders: [ 18 | { 19 | test: /\.js$/, 20 | loaders: [ 'babel' ], 21 | exclude: /node_modules/ 22 | }, 23 | { 24 | test: /\.css?$/, 25 | loaders: [ 'style', 'raw' ] 26 | }, 27 | { 28 | test: /\.json$/, 29 | loader: 'json' 30 | } 31 | ] 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["syntax-jsx", "babel-snabbdom-jsx"], 3 | "presets": ["es2015"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/README.md: -------------------------------------------------------------------------------- 1 | To build and run server: 2 | ``` 3 | npm install 4 | npm run build 5 | node start 6 | ``` 7 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/actions/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redurx'; 2 | 3 | export const selectItem = createAction(); 4 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/containers/app-container.js: -------------------------------------------------------------------------------- 1 | import h from 'snabbdom/h'; 2 | 3 | import { createLinkHandler } from '../utils'; 4 | 5 | const AppContainer = ({ history }, children) => { 6 | return ( 7 |
    8 |
    9 |

    Welcome!

    10 | 19 |
    20 |
    21 | {children} 22 |
    23 |
    24 | ) 25 | }; 26 | 27 | export default AppContainer; 28 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/containers/home-container.js: -------------------------------------------------------------------------------- 1 | import h from 'snabbdom/h'; 2 | 3 | const HomeContainer = () => { 4 | return ( 5 |
    6 |

    This is the homepage!

    7 |
    8 | ) 9 | }; 10 | 11 | export default HomeContainer; 12 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/containers/item-container.js: -------------------------------------------------------------------------------- 1 | import h from 'snabbdom/h'; 2 | 3 | const ItemContainer = ({ item }) => { 4 | return ( 5 |
    6 |

    This is the item page! The ID is {item.id}

    7 |

    {item.text}

    8 |
    9 | ) 10 | }; 11 | 12 | export default ItemContainer; 13 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/containers/items-container.js: -------------------------------------------------------------------------------- 1 | import h from 'snabbdom/h'; 2 | 3 | import { createLinkHandler } from '../utils'; 4 | 5 | const ItemsContainer = ({ list, history }) => { 6 | return ( 7 |
    8 |

    This is the items page!

    9 | 19 |
    20 | ) 21 | }; 22 | 23 | export default ItemsContainer; 24 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Snabbdom ReduRx Counter 8 | 15 | 16 | 17 |
    18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/index.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | import snabbdom from 'snabbdom'; 3 | import eventlisteners from 'snabbdom/modules/eventlisteners'; 4 | import klass from 'snabbdom/modules/class'; 5 | import props from 'snabbdom/modules/props'; 6 | import attrs from 'snabbdom/modules/attributes'; 7 | import h from 'snabbdom/h'; 8 | import { createComponentStream } from 'snabbdom-rx-utils'; 9 | import createRouter from 'observable-router'; 10 | 11 | import state from './state'; 12 | import { selectItem } from './actions'; 13 | 14 | import AppContainer from './containers/app-container'; 15 | import HomeContainer from './containers/home-container'; 16 | import ItemsContainer from './containers/items-container'; 17 | import ItemContainer from './containers/item-container'; 18 | 19 | const patch = snabbdom.init([eventlisteners, klass, props, attrs]); 20 | 21 | const AppStream = createComponentStream(null, AppContainer); 22 | const HomeStream = createComponentStream(null, HomeContainer); 23 | const ItemsStream = createComponentStream( 24 | state.compose({ list: 'list' }), 25 | ItemsContainer 26 | ); 27 | const ItemStream = createComponentStream( 28 | state.compose({ item: 'selected' }), 29 | ItemContainer 30 | ); 31 | 32 | const router = createRouter(); 33 | 34 | const vdomObservable = router 35 | .route('/', (route, history, children) => ( 36 | AppStream({ history }, children) 37 | ), sub => sub 38 | .route('/', (route) => HomeStream()) 39 | .route('/items', {}, sub => sub 40 | .route('/', (route, history) => ItemsStream({ history })) 41 | .route('/:id', { 42 | stream: (route) => ItemStream(), 43 | onNext: (route) => selectItem(route.params.id) 44 | }) 45 | ) 46 | ) 47 | .asObservable() 48 | .scan(patch, document.getElementById('root')); 49 | 50 | vdomObservable.subscribeOnError(err => { 51 | console.log('Rendering Error:', err.stack); 52 | }); 53 | 54 | router.start(); 55 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/state/index.js: -------------------------------------------------------------------------------- 1 | import { createState } from 'redurx'; 2 | 3 | import { selectItem } from '../actions'; 4 | 5 | const state = createState(); 6 | 7 | state('list', [{ 8 | id: "1", 9 | text: 'This is item 1' 10 | },{ 11 | id: "2", 12 | text: 'This is item 2' 13 | }]); 14 | 15 | state('selected', null) 16 | .reduce([selectItem, state('list')], (selected, [id, list]) => { 17 | return list.find(item => item.id === id) || null; 18 | }); 19 | 20 | state.connect(); 21 | 22 | export default state; 23 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/client/utils.js: -------------------------------------------------------------------------------- 1 | export const createLinkHandler = (history) => { 2 | return (e) => { 3 | return e.preventDefault() || history.push(e.target.getAttribute('href')) 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redurx-snabbdom-observable-router-example", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "An experiment with snabbdom and redurx", 6 | "main": "index.js", 7 | "scripts": { 8 | "clean": "rm -rf dist", 9 | "cp": "cp client/index.html dist", 10 | "build:js": "webpack", 11 | "build": "npm run clean && npm run build:js && npm run cp", 12 | "start": "node server.js" 13 | }, 14 | "author": "Ryan Lynch (github.com/shiftyp)", 15 | "license": "ISC", 16 | "dependencies": { 17 | "babel-core": "^6.10.4", 18 | "babel-loader": "^6.2.4", 19 | "babel-plugin-syntax-jsx": "6.8.0", 20 | "babel-preset-es2015": "^6.9.0", 21 | "babel-snabbdom-jsx": "0.3.0", 22 | "express": "^4.14.0", 23 | "observable-router": "^0.1.0", 24 | "raw-loader": "^0.5.1", 25 | "redurx": "^0.4.0", 26 | "rx": "4.1.0", 27 | "snabbdom": "0.5.0", 28 | "snabbdom-rx-utils": "^0.1.1", 29 | "snabbdom-jsx": "0.3.0", 30 | "style-loader": "^0.13.1", 31 | "webpack": "^1.13.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | 4 | var app = new express(); 5 | var port = process.env.PORT || 3000; 6 | 7 | app.use(express.static('./dist')); 8 | 9 | app.use('*', function(req, res) { 10 | res.sendFile(path.join(__dirname, './dist/index.html')); 11 | }); 12 | 13 | app.listen(port, function(error) { 14 | if (error) { 15 | console.error(error); 16 | } else { 17 | console.info("Listening on port:", port); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /examples/snabbdom-observable-router/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | devtool: 'source-map', 6 | entry: [ 7 | './client/index.js' 8 | ], 9 | output: { 10 | path: './dist/js', 11 | filename: 'bundle.js', 12 | publicPath: '/js/' 13 | }, 14 | plugins: [ 15 | new webpack.optimize.OccurrenceOrderPlugin() 16 | ], 17 | module: { 18 | loaders: [ 19 | { 20 | test: /\.js$/, 21 | loaders: [ 'babel' ], 22 | exclude: /node_modules/ 23 | }, 24 | { 25 | test: /\.css?$/, 26 | loaders: [ 'style', 'raw' ] 27 | }, 28 | { 29 | test: /\.json$/, 30 | loader: 'json' 31 | } 32 | ] 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /examples/todomvc/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/todomvc/README.md: -------------------------------------------------------------------------------- 1 | To build run: 2 | ``` 3 | npm install 4 | npm run build 5 | ``` 6 | The build index.html will be in the dist directory. 7 | -------------------------------------------------------------------------------- /examples/todomvc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redurx-todomvc", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "A TodoMVC example using React and ReduRx", 6 | "main": "index.js", 7 | "scripts": { 8 | "clean": "rm -rf dist", 9 | "cp": "cp src/index.html dist", 10 | "build:js": "webpack", 11 | "build": "npm run clean && npm run build:js && npm run cp" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "babel-core": "^6.10.4", 17 | "babel-loader": "^6.2.4", 18 | "babel-preset-es2015": "^6.9.0", 19 | "babel-preset-react": "^6.11.1", 20 | "classnames": "^2.2.5", 21 | "raw-loader": "^0.5.1", 22 | "react": "^15.2.0", 23 | "react-dom": "^15.2.0", 24 | "recompose": "^0.20.2", 25 | "redurx": "^0.4.0", 26 | "style-loader": "^0.13.1", 27 | "todomvc-app-css": "^2.0.6", 28 | "todomvc-common": "^1.0.2", 29 | "webpack": "^1.13.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/todomvc/src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redurx'; 2 | 3 | export const addTodo = createAction(todo => { 4 | return todo.filter(({ text }) => text.length > 0) 5 | }); 6 | export const filterTodos = createAction(); 7 | export const editNewTodo = createAction(); 8 | export const deleteTodo = createAction(); 9 | export const editTodo = createAction(); 10 | export const saveTodo = createAction(todo => { 11 | return todo 12 | .doOnNext(({ text, id, }) => { 13 | if (text === '') { 14 | deleteTodo(id); 15 | } 16 | }) 17 | .filter(({ text }) => text !== '') 18 | }); 19 | export const toggleCompleted = createAction(todo => { 20 | return todo.map(todo => Object.assign({}, todo, { 21 | completed: !todo.completed 22 | })) 23 | }) 24 | export const completeAll = createAction(); 25 | export const clearCompleted = createAction(); 26 | -------------------------------------------------------------------------------- /examples/todomvc/src/components/app.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import * as actions from '../actions'; 4 | 5 | import Header from '../components/header'; 6 | import MainSection from '../components/main-section'; 7 | 8 | const createMainSection = (display) => { 9 | if (display.counts.total > 0) { 10 | return ( 11 | 15 | ); 16 | } else { 17 | return null; 18 | } 19 | } 20 | 21 | const App = ({ editor, display }) => { 22 | return ( 23 |
    24 |
    29 | {createMainSection(display)} 30 |
    31 | ); 32 | }; 33 | 34 | App.propTypes = { 35 | display: PropTypes.object.isRequired, 36 | editor: PropTypes.object.isRequired 37 | }; 38 | 39 | export default App; 40 | -------------------------------------------------------------------------------- /examples/todomvc/src/components/footer.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classnames from 'classnames'; 3 | import { filters, filterKeys } from '../constants'; 4 | 5 | const renderTodoCount = (activeCount) => { 6 | const itemWord = activeCount === 1 ? 'item' : 'items'; 7 | 8 | return ( 9 | 10 | {activeCount || 'No'} {itemWord} left 11 | 12 | ); 13 | } 14 | 15 | const renderFilterItem = (filter, onFilter) => filterKey => { 16 | const filterTitle = filters[filterKey]; 17 | const className = classnames({ 18 | selected: filterTitle === filter 19 | }); 20 | return ( 21 |
  • 22 | onFilter(filterTitle)} 26 | > 27 | {filterTitle} 28 | 29 |
  • 30 | ); 31 | }; 32 | 33 | const Footer = ({ 34 | completed, 35 | active, 36 | filter, 37 | onClearCompleted, 38 | onFilter 39 | }) => { 40 | return ( 41 |
    42 | {renderTodoCount(active)} 43 |
      44 | {filterKeys.map(renderFilterItem(filter, onFilter))} 45 |
    46 | 52 |
    53 | ); 54 | }; 55 | 56 | Footer.propTypes = { 57 | completed: PropTypes.number.isRequired, 58 | active: PropTypes.number.isRequired, 59 | filter: PropTypes.string.isRequired, 60 | onClearCompleted: PropTypes.func.isRequired, 61 | onFilter: PropTypes.func.isRequired 62 | }; 63 | 64 | export default Footer; 65 | -------------------------------------------------------------------------------- /examples/todomvc/src/components/header.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import TodoTextInput from './todo-text-input'; 3 | 4 | const Header = ({ addTodo, editNewTodo, editor }) => { 5 | return ( 6 |
    7 |

    todos

    8 | 15 |
    16 | ) 17 | }; 18 | 19 | Header.propTypes = { 20 | addTodo: PropTypes.func.isRequired, 21 | editNewTodo: PropTypes.func.isRequired, 22 | editor: PropTypes.object.isRequired 23 | }; 24 | 25 | export default Header; 26 | -------------------------------------------------------------------------------- /examples/todomvc/src/components/main-section.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import TodoItem from './todo-item'; 4 | import Footer from './footer'; 5 | import filters from '../constants'; 6 | 7 | const MainSection = ({ 8 | actions, 9 | filter, 10 | filteredTodos, 11 | counts 12 | }) => { 13 | return ( 14 |
    15 | actions.completeAll(e.target.checked)} 20 | /> 21 |
      22 | {filteredTodos.map(todo => ( 23 | 24 | ))} 25 |
    26 |
    32 |
    33 | ) 34 | }; 35 | 36 | MainSection.propTypes = { 37 | actions: PropTypes.object.isRequired, 38 | filter: PropTypes.string.isRequired, 39 | filteredTodos: PropTypes.array.isRequired, 40 | counts: PropTypes.object.isRequired 41 | }; 42 | 43 | export default MainSection; 44 | -------------------------------------------------------------------------------- /examples/todomvc/src/components/todo-item.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classnames from 'classnames'; 3 | import TodoTextInput from './todo-text-input'; 4 | 5 | const createElement = (todo, actions) => { 6 | if (todo.editing) { 7 | return 12 | } else { 13 | return ( 14 |
    15 | actions.toggleCompleted(todo)} 20 | /> 21 | 28 |
    33 | ) 34 | } 35 | } 36 | 37 | const TodoItem = ({ todo, actions }) => { 38 | const { editing, completed } = todo 39 | const className = classnames({ 40 | completed, 41 | editing 42 | }); 43 | const element = createElement( 44 | todo, 45 | actions 46 | ); 47 | return ( 48 |
  • 49 | {element} 50 |
  • 51 | ); 52 | } 53 | 54 | export default TodoItem; 55 | -------------------------------------------------------------------------------- /examples/todomvc/src/components/todo-text-input.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | const handleBlur = (todo, onSave) => e => { 5 | onSave({ 6 | id: todo.id, 7 | text: e.target.value 8 | }); 9 | }; 10 | const handleChange = (todo, onEdit) => e => { 11 | onEdit({ 12 | id: todo.id, 13 | text: e.target.value 14 | }); 15 | }; 16 | const handleKeyDown = (todo, onEdit, onSave) => e => { 17 | const text = e.target.value.trim(); 18 | if (e.which === 13) { 19 | onSave({ 20 | id: todo.id, 21 | text 22 | }); 23 | } else { 24 | onEdit({ 25 | id: todo.id, 26 | text 27 | }); 28 | } 29 | }; 30 | const getClassName = (isNew, todo) => classnames({ 31 | edit: todo.editing, 32 | 'new-todo': isNew 33 | }); 34 | 35 | const TodoTextInput = ({ 36 | onSave, 37 | onEdit, 38 | todo, 39 | isNew, 40 | placeholder 41 | }) => { 42 | return ( 43 | 53 | ) 54 | }; 55 | 56 | TodoTextInput.propTypes = { 57 | onSave: PropTypes.func.isRequired, 58 | onEdit: PropTypes.func.isRequired, 59 | todo: PropTypes.object.isRequired, 60 | isNew: PropTypes.bool, 61 | placeholder: PropTypes.string.isRequired 62 | }; 63 | 64 | export default TodoTextInput; 65 | -------------------------------------------------------------------------------- /examples/todomvc/src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const filters = { 2 | SHOW_ALL: 'All', 3 | SHOW_COMPLETED: 'Completed', 4 | SHOW_ACTIVE: 'Active' 5 | }; 6 | 7 | export const filterKeys = Object.keys(filters); 8 | -------------------------------------------------------------------------------- /examples/todomvc/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ReduRx TodoMVC Example 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/todomvc/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import setObservableConfig from 'recompose/setObservableConfig'; 4 | import mapPropsStream from 'recompose/mapPropsStream'; 5 | import rxjs4config from 'recompose/rxjs4ObservableConfig'; 6 | import 'todomvc-app-css/index.css'; 7 | 8 | import state from './state'; 9 | import App from './components/app'; 10 | 11 | setObservableConfig(rxjs4config); 12 | 13 | const enhance = mapPropsStream(propsStream => { 14 | return propsStream 15 | .combineLatest( 16 | state.asObservable(), 17 | (props, { display, editor }) => ({ 18 | display, 19 | editor 20 | }) 21 | ); 22 | }); 23 | 24 | const EnhancedApp = enhance(App); 25 | 26 | render(, document.getElementById('root')); 27 | -------------------------------------------------------------------------------- /examples/todomvc/src/state/display.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import { filterTodos } from '../actions'; 3 | import { filters } from '../constants'; 4 | 5 | const createFilteredTodos = filter => todo => { 6 | switch(filter) { 7 | case filters.SHOW_ACTIVE: 8 | return !todo.completed; 9 | case filters.SHOW_COMPLETED: 10 | return todo.completed; 11 | default: 12 | return true; 13 | } 14 | }; 15 | 16 | const displayState = state('display') 17 | .setInitialState({ 18 | filteredTodos: null, 19 | filter: filters.SHOW_ALL, 20 | counts: { 21 | completed: null, 22 | active: null, 23 | allCompleted: null, 24 | total: null 25 | } 26 | }); 27 | 28 | displayState('filter') 29 | .reduce(filterTodos, (state, filter) => filter); 30 | 31 | displayState('filteredTodos') 32 | .reduce( 33 | [ 34 | state('todos').asObservable(), 35 | displayState('filter').asObservable() 36 | ], 37 | (filtered, [todos, filter]) => ( 38 | todos.filter(createFilteredTodos(filter)) 39 | ) 40 | ); 41 | 42 | displayState('counts') 43 | .reduce(state('todos').asObservable(), (counts, todos) => { 44 | const completedCount = todos.reduce((count, todo) => ( 45 | todo.completed ? count + 1 : count 46 | ), 0); 47 | return { 48 | completed: completedCount, 49 | active: todos.length - completedCount, 50 | allCompleted: completedCount === todos.length, 51 | total: todos.length 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /examples/todomvc/src/state/editor.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | 3 | import { addTodo, editNewTodo } from '../actions'; 4 | 5 | state('editor') 6 | .setInitialState({ 7 | id: 1, 8 | text: '', 9 | editing: false 10 | }) 11 | .reduce(addTodo, ({ id }) => ({ 12 | id: id + 1, 13 | text: '', 14 | editing: false 15 | })) 16 | .reduce(editNewTodo, ({ id }, { text }) => { 17 | return { 18 | id, 19 | text: text, 20 | editing: true 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /examples/todomvc/src/state/index.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import './todos'; 3 | import './display'; 4 | import './editor'; 5 | 6 | state.connect(); 7 | 8 | export default state; 9 | -------------------------------------------------------------------------------- /examples/todomvc/src/state/state.js: -------------------------------------------------------------------------------- 1 | import { createState } from 'redurx'; 2 | 3 | export default createState(); 4 | -------------------------------------------------------------------------------- /examples/todomvc/src/state/todos.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import { 3 | addTodo, 4 | deleteTodo, 5 | editTodo, 6 | toggleCompleted, 7 | completeAll, 8 | saveTodo, 9 | clearCompleted 10 | } from '../actions'; 11 | 12 | const updateTodo = (id, props) => todo => { 13 | if (id === null || todo.id === id) { 14 | return Object.assign({}, todo, props); 15 | } else { 16 | return todo; 17 | } 18 | }; 19 | 20 | state('todos') 21 | .setInitialState([{ 22 | id: 0, 23 | text: 'Learn ReduRx!', 24 | editing: false, 25 | completed: false 26 | }]) 27 | .reduce(addTodo, (todos, { id, text }) => ( 28 | [ 29 | { 30 | id, 31 | text, 32 | editing: false, 33 | completed: false, 34 | }, 35 | ...todos 36 | ] 37 | )) 38 | .reduce(deleteTodo, (todos, id) => ( 39 | todos.filter(todo => todo.id !== id) 40 | )) 41 | .reduce(editTodo, (todos, { id, text }) => ( 42 | todos.map(updateTodo(id, { text, editing: true })) 43 | )) 44 | .reduce(saveTodo, (todos, { id, completed, text }) => ( 45 | todos.map(updateTodo(id, { completed, text, editing: false })) 46 | )) 47 | .reduce(toggleCompleted, (todos, { id, completed }) => ( 48 | todos.map(updateTodo(id, { completed })) 49 | )) 50 | .reduce(completeAll, (todos, completed) => ( 51 | todos.map(updateTodo(null, { completed })) 52 | )) 53 | .reduce(clearCompleted, (todos) => ( 54 | todos.filter(todo => !todo.completed) 55 | )); 56 | -------------------------------------------------------------------------------- /examples/todomvc/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'source-map', 6 | entry: [ 7 | './src/index.js' 8 | ], 9 | output: { 10 | path: './dist/js', 11 | filename: 'bundle.js', 12 | publicPath: '/js/' 13 | }, 14 | plugins: [ 15 | new webpack.optimize.OccurrenceOrderPlugin() 16 | ], 17 | module: { 18 | loaders: [ 19 | { 20 | test: /\.js$/, 21 | loaders: [ 'babel' ], 22 | exclude: /node_modules/ 23 | }, 24 | { 25 | test: /\.css?$/, 26 | loaders: [ 'style', 'raw' ] 27 | } 28 | ] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /lib/action/index.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | import { isObservable, getObservable } from '../utils'; 4 | import { throwActionObservableError } from '../errors'; 5 | 6 | const createObservable = (subject, cb) => { 7 | if (typeof cb === 'function') { 8 | return cb(subject.asObservable()); 9 | } else { 10 | return subject.asObservable(); 11 | } 12 | }; 13 | 14 | export const createAction = (cb) => { 15 | const subject = new Rx.Subject(); 16 | const observable = createObservable(subject, cb); 17 | if (!isObservable(observable)) { 18 | throwActionObservableError(); 19 | } 20 | const sharedObservable = observable.share(); 21 | const action = (e) => { 22 | const nextVal = typeof e === 'undefined' ? null : e; 23 | subject.onNext(nextVal); 24 | }; 25 | return Object.assign(action, { asObservable: () => sharedObservable }); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | export const throwShapeError = () => { 2 | throw new Error('Changes to state shape are not supported.'); 3 | }; 4 | 5 | export const throwChildrenError = () => { 6 | throw new Error('Attempted to get or set children on a non-object'); 7 | }; 8 | 9 | export const throwFinalizedError = (key) => { 10 | throw new Error(`Attempting to set new value on final node ${key}`); 11 | }; 12 | 13 | export const throwActionObservableError = () => { 14 | throw new Error('Action callback did not return an observable'); 15 | }; 16 | 17 | export const throwUndefinedNextStateError = () => { 18 | throw new Error('Reducer returned undefined for next state'); 19 | }; 20 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | export * from './state'; 3 | export * from './action'; 4 | -------------------------------------------------------------------------------- /lib/state/hooks.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | import { getObservable } from '../utils'; 4 | import { throwUndefinedNextStateError } from '../errors'; 5 | 6 | const createWrapHookObservable = (map, pauser) => { 7 | const wrapObservable = (observable) => { 8 | const memo = map.get(observable); 9 | if (memo) return memo; 10 | const wrapped = observable 11 | .doOnNext(() => pauser.onNext(false)) 12 | .doOnError(() => pauser.onNext(false)) 13 | .flatMapObserver( 14 | val => { 15 | return Rx.Observable 16 | .just(val) 17 | .doOnCompleted(() => pauser.onNext(true)); 18 | }, 19 | err => { 20 | return Rx.Observable.create((o) => { 21 | o.onError(err); 22 | pauser.onNext(true); 23 | }) 24 | } 25 | ) 26 | .startWith(null) 27 | .share(); 28 | map.set(observable, wrapped); 29 | return wrapped; 30 | } 31 | return (actions) => { 32 | if (Array.isArray(actions)) { 33 | const observables = actions 34 | .map(getObservable) 35 | .map(wrapObservable); 36 | return Rx.Observable.combineLatest( 37 | ...observables 38 | ); 39 | } else { 40 | return wrapObservable(getObservable(actions)); 41 | } 42 | }; 43 | }; 44 | 45 | export const createReduce = (observable, nodeObservable, hookMap, pauser) => { 46 | const hookSubject = new Rx.ReplaySubject(); 47 | const createHookObservable = createWrapHookObservable(hookMap, pauser) 48 | const connectReducer = (obs, setNextState, reducer) => { 49 | obs 50 | .subscribeOnNext(([vals, state]) => { 51 | const nextState = reducer(state, vals); 52 | if (typeof nextState === 'undefined') { 53 | throwUndefinedNextStateError(); 54 | } else { 55 | setNextState(nextState); 56 | } 57 | }) 58 | }; 59 | 60 | const apiRealizationObservable = Rx.Observable 61 | .combineLatest( 62 | nodeObservable.filter(node => node && !node.provisional), 63 | hookSubject, 64 | ({ setNextState }, { hookObservable, reducer }) => { 65 | const nextSubject = new Rx.Subject(); 66 | const nextObservable = nextSubject.withLatestFrom(observable); 67 | 68 | hookObservable.subscribe(nextSubject) 69 | connectReducer(nextObservable, setNextState, reducer) 70 | 71 | return true; 72 | } 73 | ); 74 | 75 | apiRealizationObservable.subscribeOnError(error => { throw error }); 76 | 77 | const reduce = (actions, reducer) => { 78 | const hookObservable = createHookObservable(actions); 79 | 80 | hookSubject.onNext({ hookObservable, reducer }); 81 | return makeApi(); 82 | }; 83 | 84 | const makeApi = () => ({ 85 | reduce: createReduce( 86 | observable, 87 | nodeObservable, 88 | hookMap, 89 | pauser 90 | ) 91 | }); 92 | 93 | return reduce; 94 | }; 95 | -------------------------------------------------------------------------------- /lib/state/index.js: -------------------------------------------------------------------------------- 1 | import { createTree } from './tree'; 2 | import { createNode, publishNode } from './node'; 3 | 4 | export const createState = (initialState, passedCreateNode) => ( 5 | publishNode(createTree({ initialState, createNode: passedCreateNode || createNode })) 6 | ); 7 | -------------------------------------------------------------------------------- /lib/state/node.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | import { createReduce } from './hooks'; 4 | import { createTree } from './tree'; 5 | import { pick, isObservable } from '../utils'; 6 | import { throwFinalizedError, throwChildrenError } from '../errors'; 7 | 8 | const exposedNodeProps = [ 9 | 'reduce', 10 | 'asObservable', 11 | 'setInitialState', 12 | 'connect', 13 | 'compose' 14 | ]; 15 | 16 | export const createNodeAccessor = node => (path, value) => { 17 | const keys = path.split('.'); 18 | return keys.reduce((acc, key, i) => { 19 | if (typeof value !== 'undefined' && i === keys.length - 1) { 20 | return acc.child(key, value); 21 | } 22 | return acc.child(key) 23 | }, node); 24 | }; 25 | 26 | export const createChildAccessor = (addChildrenSubject, getChildrenSubject) => { 27 | return (key, value) => { 28 | if (!getChildrenSubject) throwChildrenError(); 29 | const beforeChildren = getChildrenSubject.getValue(); 30 | const nodeSubject = beforeChildren[key]; 31 | if (!nodeSubject || nodeSubject.getValue().provisional) { 32 | if (typeof value !== 'undefined') { 33 | addChildrenSubject.onNext({ action: 'add', key, value, provisional: false }); 34 | } else if (!nodeSubject) { 35 | addChildrenSubject.onNext({ action: 'add', key, value: null, provisional: true }); 36 | } 37 | } 38 | return getChildrenSubject.getValue()[key].getValue(); 39 | }; 40 | }; 41 | 42 | export const createFinalNodeFromProvisionalNode = ({ 43 | observable, 44 | provisionalNode, 45 | setNextState, 46 | setCompleted 47 | }) => { 48 | const { observableSubject, nodeSubject } = provisionalNode; 49 | 50 | if (observable) { 51 | observableSubject.onNext(observable); 52 | } 53 | 54 | const nodeProps = Object.assign({}, provisionalNode, { 55 | provisional: false 56 | }); 57 | 58 | if (typeof setNextState === 'function') { 59 | nodeProps.setNextState = setNextState; 60 | } 61 | if (typeof setCompleted === 'function') { 62 | nodeProps.setCompleted = setCompleted; 63 | } 64 | 65 | const node = Object.assign( 66 | wrapInPublish(createNodeAccessor(nodeProps)), 67 | nodeProps 68 | ); 69 | 70 | nodeSubject.onNext(node); 71 | 72 | return node; 73 | }; 74 | 75 | export const createInitialNode = ({ 76 | addChildrenSubject, 77 | getChildrenSubject, 78 | pauser, 79 | observable, 80 | hookMap, 81 | setNextState, 82 | setCompleted, 83 | provisional 84 | }) => { 85 | const observableSubject = new Rx.ReplaySubject(1); 86 | // Great name right? 87 | const combinedObservable = observableSubject 88 | .flatMapLatest(obs => obs); 89 | const externalObservable = combinedObservable 90 | .replay(); 91 | const child = createChildAccessor(addChildrenSubject, getChildrenSubject); 92 | const nodeSubject = new Rx.BehaviorSubject(); 93 | const asObservable = () => externalObservable; 94 | const reduce = createReduce( 95 | combinedObservable, 96 | nodeSubject.asObservable(), 97 | hookMap, 98 | pauser 99 | ); 100 | const connectDisposable = new Rx.CompositeDisposable(); 101 | const connect = () => { 102 | const children = getChildrenSubject && getChildrenSubject.getValue(); 103 | if (children) { 104 | const keys = Object.keys(children); 105 | keys.forEach(key => connectDisposable.add( 106 | children[key].getValue().connect() 107 | )); 108 | } 109 | connectDisposable.add( 110 | asObservable().connect() 111 | ); 112 | return connectDisposable; 113 | }; 114 | 115 | const setInitialState = (initialState) => { 116 | const currentNode = nodeSubject.getValue() 117 | if (!currentNode.provisional) { 118 | throwFinalizedError(); 119 | } else { 120 | createTree({ 121 | initialState, 122 | createNode, 123 | pauser, 124 | hookMap, 125 | provisional: false, 126 | provisionalNode: node 127 | }); 128 | return publishNode(nodeSubject.getValue()); 129 | } 130 | }; 131 | const setNodeCompleted = () => { 132 | setCompleted(); 133 | observableSubject.onCompleted(); 134 | // Dispose on next tick so onComplete handlers 135 | // will be invoked. 136 | setTimeout(() => connectDisposable.dispose()); 137 | }; 138 | 139 | if (observable) { 140 | observableSubject.onNext(observable); 141 | } 142 | 143 | const nodeProps = { 144 | addChildrenSubject, 145 | getChildrenSubject, 146 | reduce, 147 | child, 148 | setNextState, 149 | setCompleted: setNodeCompleted, 150 | nodeSubject, 151 | provisional: !!provisional, 152 | provisionalNode: !!provisional && node, 153 | pauser, 154 | combinedObservable, 155 | observableSubject, 156 | asObservable, 157 | connect, 158 | setInitialState 159 | }; 160 | 161 | const accessor = createNodeAccessor(nodeProps); 162 | 163 | const compose = nodeProps.compose = wrapInPublish((nodeMap) => createComposedNode({ 164 | nodeMap, 165 | accessor, 166 | pauser, 167 | hookMap 168 | })); 169 | 170 | const node = Object.assign( 171 | wrapInPublish(accessor), 172 | nodeProps 173 | ); 174 | 175 | nodeSubject.onNext(node); 176 | 177 | return node; 178 | }; 179 | 180 | const createComposedNode = ({ 181 | nodeMap, 182 | accessor, 183 | pauser, 184 | hookMap 185 | }) => { 186 | const nodeSubject = new Rx.ReplaySubject(1); 187 | const keys = Object.keys(nodeMap); 188 | const compositeAccessor = (key) => { 189 | const path = nodeMap[key]; 190 | if (typeof path === 'undefined') { 191 | throw new Error(`Key ${key} not found on composite node`); 192 | } 193 | return accessor(nodeMap[key]).nodeSubject.getValue(); 194 | }; 195 | const combinedObservable = Rx.Observable 196 | .combineLatest( 197 | ...keys.map(key => { 198 | const path = nodeMap[key]; 199 | const node = accessor(path); 200 | if (!node) { 201 | throw new Error( 202 | `Bad path to node: ${path} 203 | Cannot compose nodes that have not been previously defined.` 204 | ); 205 | } 206 | return node.nodeSubject; 207 | }) 208 | ) 209 | .flatMapLatest(nodes => { 210 | return Rx.Observable 211 | .combineLatest( 212 | ...nodes.map(node => { 213 | return node.combinedObservable 214 | }) 215 | ) 216 | .map(values => { 217 | return values.reduce((acc, val, i) => { 218 | return Object.assign({}, acc, { 219 | [keys[i]]: val 220 | }); 221 | }, {}) 222 | } 223 | ) 224 | }) 225 | .pausable(pauser); 226 | const externalObservable = combinedObservable.shareReplay(1); 227 | const reduce = createReduce( 228 | combinedObservable, 229 | nodeSubject.asObservable(), 230 | hookMap, 231 | pauser 232 | ); 233 | const setNextState = (state) => { 234 | const newKeys = Object.keys(state); 235 | newKeys.forEach(key => { 236 | accessor(nodeMap[key]).nodeSubject 237 | .getValue() 238 | .setNextState(state[key]); 239 | }); 240 | }; 241 | const asObservable = () => externalObservable; 242 | // Non implemented functions 243 | const compose = () => { 244 | throw new Error('Composite nodes cannot be further composed'); 245 | } 246 | const connect = () => { 247 | throw new Error( 248 | `Composite nodes cannot be connected. 249 | Connect from the original state tree` 250 | ); 251 | }; 252 | const setInitialState = () => { 253 | throw new Error( 254 | `Cannot set initial state on a composite node 255 | Set initial state from the original state tree` 256 | ) 257 | } 258 | 259 | const node = Object.assign( 260 | wrapInPublish(compositeAccessor), 261 | { 262 | nodeSubject, 263 | reduce, 264 | setNextState, 265 | compose, 266 | connect, 267 | asObservable, 268 | setInitialState 269 | } 270 | ); 271 | 272 | nodeSubject.onNext(node); 273 | 274 | return node; 275 | }; 276 | 277 | export const createNode = ({ 278 | addChildrenSubject, 279 | getChildrenSubject, 280 | pauser, 281 | observable, 282 | hookMap, 283 | setNextState, 284 | setCompleted, 285 | provisional, 286 | provisionalNode 287 | }) => { 288 | if (provisionalNode) { 289 | return createFinalNodeFromProvisionalNode({ 290 | observable, 291 | setNextState, 292 | setCompleted, 293 | provisionalNode 294 | }); 295 | } else { 296 | return createInitialNode({ 297 | addChildrenSubject, 298 | getChildrenSubject, 299 | pauser, 300 | observable, 301 | hookMap, 302 | setNextState, 303 | setCompleted, 304 | provisional 305 | }); 306 | } 307 | }; 308 | 309 | // arrow functions don't have `arguments` 310 | export const wrapInPublish = (accessor) => function() { 311 | return publishNode(accessor.apply(null, arguments)); 312 | }; 313 | 314 | export const publishNode = node => Object.assign(function() { 315 | return node.apply(null, arguments); 316 | }, pick(node, exposedNodeProps)); 317 | -------------------------------------------------------------------------------- /lib/state/tree.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | import { isPlainObject } from '../utils'; 4 | import { 5 | throwShapeError, 6 | throwChildrenError, 7 | throwFinalizedError 8 | } from '../errors'; 9 | 10 | export const createTreeSetNextState = ( 11 | childrenObservable, 12 | addChildrenSubject, 13 | pauser 14 | ) => { 15 | const newStateSubject = new Rx.Subject(); 16 | newStateSubject 17 | .withLatestFrom(childrenObservable) 18 | .subscribe(([newState, children]) => { 19 | const keys = Object.keys(children); 20 | const newKeys = Object.keys(newState); 21 | const additionalState = {}; 22 | const pruneState = {}; 23 | 24 | newKeys.forEach(key => { 25 | const child = children[key]; 26 | if (child && !child.getValue().provisional) { 27 | children[key].getValue().setNextState(newState[key]); 28 | } else { 29 | Object.assign(additionalState, { [key]: newState[key] }) 30 | } 31 | }); 32 | keys.forEach(key => { 33 | if (!(key in newState)) { 34 | Object.assign(pruneState, { [key]: true }); 35 | } 36 | }); 37 | 38 | updateAddChildrenSubject(additionalState, pruneState, addChildrenSubject, pauser) 39 | }); 40 | 41 | return (newState) => { 42 | newStateSubject.onNext(newState); 43 | }; 44 | }; 45 | 46 | export const createTreeObservable = (childrenObservable) => { 47 | return childrenObservable 48 | .flatMapLatest(children => { 49 | const keys = Object.keys(children); 50 | return Rx.Observable.combineLatest( 51 | ...keys.map(key => { 52 | return children[key] 53 | }) 54 | ,(...latestNodes) => { 55 | return latestNodes.reduce((acc, val, i) => { 56 | if (!val.provisional) { 57 | acc[keys[i]] = val; 58 | } 59 | return acc; 60 | }, {}); 61 | }); 62 | }) 63 | .flatMapLatest(nodes => { 64 | const keys = Object.keys(nodes); 65 | return Rx.Observable 66 | .combineLatest( 67 | ...keys 68 | .map(key => nodes[key].combinedObservable), 69 | (...latestValues) => { 70 | return latestValues.reduce((acc, val, i) => { 71 | acc[keys[i]] = val; 72 | return acc; 73 | }, {}); 74 | }); 75 | }); 76 | }; 77 | 78 | export const createChildrenObservable = ({ 79 | addChildrenSubject, 80 | getChildrenSubject, 81 | pauser, 82 | hookMap, 83 | createNode 84 | }) => { 85 | return addChildrenSubject 86 | .withLatestFrom(getChildrenSubject, (child, acc) => { 87 | const { action, key, value, provisional } = child; 88 | if (action === 'add') { 89 | const oldNode = acc[key] && acc[key].getValue(); 90 | if(!oldNode || oldNode.provisional) { 91 | const newNode = createTree({ 92 | initialState: value, 93 | pauser, 94 | hookMap, 95 | createNode, 96 | provisional, 97 | provisionalNode: oldNode 98 | }); 99 | return Object.assign({}, acc, { 100 | [key]: newNode.nodeSubject 101 | }); 102 | } else if (!oldNode.provisional) { 103 | throwFinalizedError(key); 104 | } else { 105 | return acc; 106 | } 107 | } else { 108 | const node = acc[key] && acc[key].getValue(); 109 | if (node) { 110 | node.setCompleted(); 111 | delete acc[node]; 112 | } 113 | return acc; 114 | } 115 | }) 116 | .shareReplay(1); 117 | }; 118 | 119 | export const updateAddChildrenSubject = (addState, pruneState, addChildrenSubject, pauser) => { 120 | const addKeys = Object.keys(addState); 121 | pauser.onNext(false); 122 | addKeys.forEach((key) => { 123 | addChildrenSubject.onNext( 124 | { action: 'add', key, value: addState[key], provisional: false } 125 | ); 126 | }); 127 | if (pruneState) { 128 | const pruneKeys = Object.keys(pruneState); 129 | pruneKeys.forEach(key => { 130 | addChildrenSubject.onNext({ action: 'prune', key }); 131 | }) 132 | } 133 | pauser.onNext(true); 134 | }; 135 | 136 | export const createFinalTreeFromProvisionalNode = ({ 137 | initialState, 138 | createNode, 139 | provisionalNode 140 | }) => { 141 | const { 142 | addChildrenSubject, 143 | pauser 144 | } = provisionalNode; 145 | updateAddChildrenSubject(initialState, null, addChildrenSubject, pauser); 146 | return createNode({ 147 | provisional: false, 148 | provisionalNode 149 | }); 150 | } 151 | 152 | const createInitialTree = ({ 153 | initialState, 154 | rootPauser, 155 | hookMap, 156 | createNode, 157 | provisional, 158 | provisionalNode 159 | }) => { 160 | const pauser = new Rx.BehaviorSubject(true); 161 | if (rootPauser) rootPauser.subscribe(pauser.onNext.bind(pauser)); 162 | if (!hookMap) { 163 | hookMap = new WeakMap(); 164 | } 165 | const addChildrenSubject = new Rx.Subject(); 166 | const getChildrenSubject = new Rx.BehaviorSubject({}); 167 | const childrenObservable = createChildrenObservable({ 168 | addChildrenSubject, 169 | getChildrenSubject, 170 | pauser, 171 | hookMap, 172 | createNode 173 | }); 174 | 175 | const valueSubject = new Rx.ReplaySubject(1); 176 | 177 | childrenObservable.subscribe(getChildrenSubject) 178 | childrenObservable.subscribeOnError(err => { throw err }); 179 | 180 | if (!provisional) { 181 | updateAddChildrenSubject(initialState, null, addChildrenSubject, pauser); 182 | } 183 | 184 | const valueObservable = createTreeObservable(childrenObservable) 185 | .pausable(pauser); 186 | const setNextState = createTreeSetNextState(childrenObservable, addChildrenSubject, pauser); 187 | const setCompleted = () => { 188 | addChildrenSubject.onCompleted(); 189 | getChildrenSubject.onCompleted(); 190 | valueSubject.onCompleted(); 191 | }; 192 | 193 | valueObservable.subscribe(valueSubject); 194 | 195 | const node = createNode({ 196 | addChildrenSubject, 197 | getChildrenSubject, 198 | pauser, 199 | observable: valueSubject.asObservable(), 200 | hookMap, 201 | setNextState, 202 | setCompleted, 203 | provisional, 204 | provisionalNode 205 | }); 206 | 207 | return node; 208 | }; 209 | 210 | export const createLeaf = ({ 211 | initialState, 212 | pauser, 213 | createNode, 214 | hookMap, 215 | provisionalNode 216 | }) => { 217 | if ( 218 | provisionalNode && 219 | Object.keys(provisionalNode.getChildrenSubject.getValue()).length 220 | ) { 221 | throw new Error('Tried to create leaf node when provisional has children'); 222 | } 223 | if (!pauser) { 224 | pauser = new Rx.BehaviorSubject(true); 225 | } 226 | if (!hookMap) { 227 | hookMap = new WeakMap(); 228 | } 229 | const subject = new Rx.BehaviorSubject(initialState); 230 | const observable = subject 231 | .distinctUntilChanged(); 232 | const setNextState = (newState) => { 233 | subject.onNext(newState); 234 | }; 235 | const setCompleted = () => { 236 | subject.onCompleted(); 237 | }; 238 | 239 | return createNode({ 240 | observable, 241 | hookMap, 242 | pauser, 243 | setNextState, 244 | setCompleted, 245 | provisional: false, 246 | provisionalNode 247 | }); 248 | }; 249 | 250 | export const createTree = ({ 251 | initialState, 252 | pauser, 253 | hookMap, 254 | createNode, 255 | provisional, 256 | provisionalNode 257 | }) => { 258 | if (typeof initialState === 'undefined') { 259 | return createInitialTree({ 260 | initialState: null, 261 | pauser, 262 | hookMap, 263 | createNode, 264 | provisional: true 265 | }); 266 | } else if (!provisional) { 267 | if (!isPlainObject(initialState)) { 268 | return createLeaf({ 269 | initialState, 270 | pauser, 271 | createNode, 272 | hookMap, 273 | provisionalNode 274 | }); 275 | } 276 | else if (provisionalNode) { 277 | return createFinalTreeFromProvisionalNode({ 278 | initialState, 279 | createNode, 280 | provisionalNode 281 | }); 282 | } 283 | } 284 | 285 | return createInitialTree({ 286 | initialState, 287 | pauser, 288 | hookMap, 289 | createNode, 290 | provisional, 291 | provisionalNode 292 | }); 293 | }; 294 | 295 | export default createTree; 296 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | export const isPlainObject = value => ( 4 | isObject(value) && !isObservable(value) 5 | ); 6 | 7 | export const isObject = value => ( 8 | typeof value === 'object' && value !== null && !Array.isArray(value) 9 | ); 10 | 11 | export const isObservable = value => ( 12 | value instanceof Rx.Observable || isSubject(value) 13 | ); 14 | 15 | export const isSubject = value => ( 16 | !![Rx.Subject, Rx.BehaviorSubject, Rx.ReplaySubject, Rx.AsyncSubject] 17 | .find(c => value instanceof c) 18 | ); 19 | 20 | export const getObservable = value => { 21 | if (isObservable(value)) { 22 | return value; 23 | } else if (typeof value.asObservable === 'function') { 24 | return value.asObservable(); 25 | } else { 26 | throw new TypeError('Invalid Observable'); 27 | } 28 | }; 29 | 30 | export const pick = (obj, keys) => { 31 | return keys.reduce((acc, key) => { 32 | acc[key] = obj[key]; 33 | return acc; 34 | }, {}); 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redurx", 3 | "version": "0.4.1", 4 | "description": "Redux'ish Functional State Management using RxJS", 5 | "keywords": [ 6 | "redurx", 7 | "redux", 8 | "reducer", 9 | "state", 10 | "predictable", 11 | "functional", 12 | "observable", 13 | "rx", 14 | "rxjs", 15 | "immutable", 16 | "flux", 17 | "frp", 18 | "reactive" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/shiftyp/redurx.git" 23 | }, 24 | "main": "dist/index.js", 25 | "scripts": { 26 | "clean": "rm -rf ./browser && rm -rf ./dist", 27 | "build:dist": "babel lib -d dist", 28 | "build:browser": "webpack", 29 | "prepublish": "npm run clean && npm run build:dist && npm run build:browser", 30 | "test": "npm run build:dist && ava" 31 | }, 32 | "author": "Ryan Lynch (https://github.com/shiftyp)", 33 | "bugs": { 34 | "url": "https://github.com/reactjs/redurx/issues" 35 | }, 36 | "license": "ISC", 37 | "dependencies": { 38 | "babel-polyfill": "^6.9.1", 39 | "babel-snabbdom-jsx": "0.3.0", 40 | "rx": "^4.1.0" 41 | }, 42 | "devDependencies": { 43 | "ava": "0.15.2", 44 | "babel-cli": "6.10.1", 45 | "babel-loader": "6.2.4", 46 | "babel-preset-es2015": "6.9.0", 47 | "webpack": "1.13.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/action/index.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | import test from 'ava'; 3 | 4 | import { createAction, connectAction } from '../../dist/action'; 5 | 6 | test('createAction should return a function', t => { 7 | const action = createAction(); 8 | t.true(typeof action === 'function'); 9 | }); 10 | 11 | test('createAction should accept a callback', t => { 12 | const testVal = 1; 13 | const cb = (obs) => { 14 | t.true(obs instanceof Rx.Observable); 15 | return obs.map((val) => val + 1); 16 | }; 17 | t.plan(3); 18 | const action = createAction(cb); 19 | const observable = action.asObservable(); 20 | t.true(observable instanceof Rx.Observable); 21 | observable.subscribe(val => t.is(val, testVal + 1)); 22 | action(testVal); 23 | }); 24 | 25 | test('createAction should throw error if callback does not return an observable', t => { 26 | const cbs = [ 27 | () => undefined, 28 | () => null, 29 | () => {}, 30 | () => 1 31 | ]; 32 | 33 | cbs.forEach(cb => { 34 | t.throws(() => createAction(cb)); 35 | }); 36 | }); 37 | 38 | test('createAction should accept no arguments', t => { 39 | const action = createAction(); 40 | t.true(action.asObservable() instanceof Rx.Observable); 41 | }); 42 | 43 | test('action should push a passed value onto the stream', t => { 44 | const testValue = {}; 45 | const action = createAction(); 46 | const subscription = action.asObservable().subscribe( 47 | value => t.is(value, testValue), 48 | err => t.fail(err) 49 | ); 50 | t.plan(1); 51 | action(testValue); 52 | }); 53 | -------------------------------------------------------------------------------- /test/state/hooks.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import Rx from 'rx'; 3 | 4 | import { createState } from '../../dist/state'; 5 | 6 | test('should be able to hook into tree node', t => { 7 | const testErr = new Error('bar'); 8 | const testVal = 42; 9 | const initialVal = { 10 | a: 1, 11 | b: { 12 | val: 2 13 | } 14 | }; 15 | const finalVal = { 16 | a: 43, 17 | b: { 18 | val: 53 19 | } 20 | }; 21 | const state = createState(); 22 | const action = new Rx.Subject(); 23 | 24 | state('foo.bar').setInitialState(initialVal); 25 | 26 | state('foo.bar').reduce(action, (state, val) => { 27 | t.deepEqual(state, initialVal); 28 | t.is(val, testVal); 29 | return finalVal 30 | }) 31 | 32 | t.plan(3); 33 | 34 | state('foo.bar').asObservable().skip(1) 35 | .subscribe(val => t.deepEqual(val, finalVal)); 36 | 37 | state.connect(); 38 | 39 | action.onNext(testVal); 40 | }); 41 | 42 | test('should be able to hook into leaf node on next and error', t => { 43 | const testErr = new Error('baz'); 44 | const testVal = 42; 45 | const initialVal = 12; 46 | const finalVal = 54 47 | const state = createState(); 48 | const action = new Rx.Subject(); 49 | 50 | state('foo.bar').setInitialState(initialVal); 51 | 52 | state('foo.bar').reduce(action, (state, val) => { 53 | t.is(state, initialVal); 54 | t.is(val, testVal); 55 | return finalVal; 56 | }); 57 | 58 | t.plan(3); 59 | 60 | state('foo.bar').asObservable().skip(1) 61 | .subscribe(val => t.deepEqual(val, finalVal)); 62 | 63 | state.connect(); 64 | 65 | action.onNext(testVal); 66 | }); 67 | 68 | test('should be able to hook into multiple observables', t => { 69 | const finalVals = [ 70 | [ 71 | 1, 72 | null 73 | ], 74 | [ 75 | 1, 76 | 2 77 | ] 78 | ] 79 | const state = createState(); 80 | const action1 = new Rx.Subject(); 81 | const action2 = new Rx.Subject(); 82 | state('foo.bar', null).reduce([action1, action2], (state, vals) => { 83 | return vals; 84 | }) 85 | 86 | t.plan(1); 87 | 88 | 89 | state('foo.bar').asObservable().skip(1).take(2).toArray() 90 | .subscribe(vals => t.deepEqual(vals, finalVals)); 91 | 92 | state.connect(); 93 | 94 | action1.onNext(finalVals[0][0]); 95 | action2.onNext(finalVals[1][1]); 96 | }); 97 | 98 | test('An error should be thrown if a reducer returns undefined', t => { 99 | const state = createState({ foo: 1 }); 100 | const node = state('foo'); 101 | const action = new Rx.Subject(); 102 | 103 | node.reduce(action, () => undefined); 104 | 105 | t.throws(() => action.onNext(1)); 106 | }); 107 | 108 | test('An error should be thrown if an error occurs within an action', t => { 109 | const state = createState({ foo: 1 }); 110 | const node = state('foo'); 111 | const action = new Rx.Subject(); 112 | 113 | node.reduce( 114 | action.doOnNext(() => { throw new Error('bar') }), 115 | () => 2 116 | ); 117 | 118 | t.throws(() => action.onNext(3)); 119 | }); 120 | -------------------------------------------------------------------------------- /test/state/node.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import Rx from 'rx'; 3 | 4 | import { createState } from '../../dist/state'; 5 | import { createAction } from '../../dist/action'; 6 | 7 | test('should be able to hook into leaf node observable prior to initial state', t => { 8 | const testVal = 42; 9 | const initialVal = 12; 10 | const finalVal = 54; 11 | const state = createState(); 12 | const action = new Rx.Subject(); 13 | 14 | state.connect(); 15 | 16 | state('foo.bar').reduce(action, (state, val) => { 17 | t.is(state, initialVal); 18 | t.is(val, testVal); 19 | return finalVal; 20 | }); 21 | 22 | t.plan(3); 23 | 24 | state('foo.bar').asObservable().skip(1) 25 | .subscribe(val => t.is(val, finalVal)); 26 | 27 | state('foo.bar') 28 | .setInitialState(initialVal) 29 | .connect(); 30 | 31 | action.onNext(testVal); 32 | }); 33 | 34 | test('should be able to hook into tree node observable prior to initial state', t => { 35 | const testVal = 42; 36 | const initialVal = { 37 | a: 1, 38 | b: 2 39 | }; 40 | const finalVal = { 41 | a: 43, 42 | b: 44 43 | } 44 | const state = createState(); 45 | const action = new Rx.Subject(); 46 | state('foo.bar').reduce(action, (state, val) => { 47 | t.deepEqual(state, initialVal); 48 | t.is(val, testVal); 49 | return finalVal; 50 | }); 51 | 52 | t.plan(3); 53 | 54 | state('foo.bar').asObservable().skip(1) 55 | .subscribe(val => t.deepEqual(val, finalVal)); 56 | 57 | state('foo.bar').setInitialState(initialVal); 58 | 59 | state.connect(); 60 | 61 | action.onNext(testVal); 62 | }); 63 | 64 | test('setInitialState should throw an error if called on a final node', t => { 65 | const state = createState(); 66 | const node = state('foo'); 67 | node.setInitialState(1); 68 | t.throws(() => node.setInitialState(2)); 69 | t.throws(() => state.setInitialState({ foo: 3 })); 70 | }); 71 | 72 | test('setInitialState should throw an error if children are added to a leaf node', t=> { 73 | const state = createState(); 74 | const node = state('foo'); 75 | node.setInitialState(1); 76 | t.throws(() => state.setInitialState({ foo: { bar: 1 }})); 77 | }); 78 | 79 | test('node accessor should take initial state', t => { 80 | const fooState = 1; 81 | const bazState = { 82 | qux: 2 83 | }; 84 | const state = createState(); 85 | const foo = state('foo', fooState); 86 | 87 | t.plan(2); 88 | 89 | foo.asObservable().subscribe(val => t.is(val, fooState)); 90 | 91 | state.connect(); 92 | 93 | const baz = state('bar.baz', bazState); 94 | 95 | baz.asObservable().subscribe(val => t.deepEqual(val, bazState)) 96 | 97 | baz.connect(); 98 | }); 99 | 100 | test('compose should create a node with values composed of the passed paths', t => { 101 | const expectedStates = [{ 102 | foo: 1, 103 | baz: 1, 104 | foobaz: 1 105 | }, { 106 | foo: 2, 107 | baz: 2, 108 | foobaz: 2 109 | }]; 110 | const state = createState({ 111 | foo: 1, 112 | bar: { 113 | baz: 1 114 | }, 115 | qux: { 116 | foobaz: 1 117 | } 118 | }); 119 | const composed = state.compose({ 120 | foo: 'foo', 121 | baz: 'bar.baz', 122 | foobaz: 'qux.foobaz' 123 | }); 124 | const action = createAction(); 125 | 126 | composed.asObservable().take(2).toArray() 127 | .subscribe(states => t.deepEqual(states, expectedStates)); 128 | 129 | state.reduce(action, () => ({ 130 | foo: 2, 131 | bar: { 132 | baz: 2 133 | }, 134 | qux: { 135 | foobaz: 2 136 | } 137 | })); 138 | 139 | state.connect(); 140 | 141 | t.plan(1); 142 | 143 | action(); 144 | }); 145 | 146 | 147 | test('composed node state should propogate reduced state to the nodes it is composed of', t => { 148 | const expectedStates = [{ 149 | foo: 1, 150 | bar: { 151 | baz: 1 152 | }, 153 | qux: { 154 | foobaz: 1 155 | } 156 | }, { 157 | foo: 2, 158 | bar: { 159 | baz: 2 160 | }, 161 | qux: { 162 | foobaz: 2 163 | } 164 | }]; 165 | const state = createState(expectedStates[0]); 166 | const composed = state.compose({ 167 | foo: 'foo', 168 | baz: 'bar.baz', 169 | foobaz: 'qux.foobaz' 170 | }); 171 | const action = createAction(); 172 | 173 | composed.reduce(action, () => ({ 174 | foo: 2, 175 | baz: 2, 176 | foobaz: 2 177 | })); 178 | 179 | state.asObservable().take(2).toArray() 180 | .subscribe(states => t.deepEqual(states, expectedStates)); 181 | 182 | state.connect(); 183 | 184 | t.plan(1); 185 | 186 | action(); 187 | }); 188 | 189 | test('composed node accessor should return the nodes it is composed of', t => { 190 | const state = createState({ foo: 1 }); 191 | const composed = state.compose({ bar: 'foo' }); 192 | 193 | t.is(state('foo').asObservable(), composed('bar').asObservable()); 194 | }) 195 | -------------------------------------------------------------------------------- /test/state/tree.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import Rx from 'rx'; 3 | 4 | import { 5 | createState 6 | } from '../../dist/state'; 7 | import { 8 | createNode 9 | } from '../../dist/state/node'; 10 | import { 11 | createAction 12 | } from '../../dist/action'; 13 | 14 | test('createLeaf observable should have initial value', t => { 15 | const testVal = 2; 16 | t.plan(1); 17 | const state = createState(testVal) 18 | state 19 | .asObservable() 20 | .subscribe((val) => t.is(val, testVal)); 21 | state.connect(); 22 | }); 23 | 24 | test('createLeaf observable should send distinct values from setNextState', t => { 25 | const testVals = [1, 2, 3]; 26 | const state = createState()('foo.bar', testVals[0]); 27 | const action = new Rx.Subject(); 28 | 29 | t.plan(1); 30 | 31 | state.reduce(action, (state, val) => val) 32 | 33 | state 34 | .asObservable() 35 | .take(3) 36 | .toArray() 37 | .subscribe((vals) => t.deepEqual(vals, testVals)); 38 | 39 | state.connect(); 40 | 41 | action.onNext(testVals[1]); 42 | action.onNext(testVals[1]); 43 | action.onNext(testVals[2]); 44 | action.onNext(testVals[2]); 45 | }); 46 | 47 | test('createTree should create leaf nodes for passed children', t => { 48 | const state = { 49 | foo: 1, 50 | bar: 'string', 51 | baz: true, 52 | qux: null 53 | }; 54 | const stateKeys = Object.keys(state).sort(); 55 | const wrappedCreateNode = function({ getChildrenSubject, observable }) { 56 | let children; 57 | 58 | if (getChildrenSubject) { 59 | children = getChildrenSubject.getValue(); 60 | t.deepEqual(stateKeys, Object.keys(children).sort()); 61 | } else { 62 | t.pass(); 63 | } 64 | return createNode.apply(null, arguments); 65 | }; 66 | t.plan(stateKeys.length + 1); 67 | createState(state, wrappedCreateNode); 68 | }); 69 | 70 | test('createTree should create tree nodes for passed children', t => { 71 | const state = { 72 | obj: { 73 | foo: 1, 74 | bar: 'string', 75 | baz: true, 76 | qux: null 77 | } 78 | }; 79 | const nestedStateKeys = Object.keys(state.obj).sort(); 80 | const wrappedCreateNode = function({ getChildrenSubject, observable }) { 81 | let children; 82 | 83 | if (getChildrenSubject) { 84 | children = getChildrenSubject.getValue(); 85 | if (!children.hasOwnProperty('obj')) { 86 | t.deepEqual(nestedStateKeys, Object.keys(children).sort()); 87 | } 88 | } else { 89 | t.pass(); 90 | } 91 | return createNode.apply(null, arguments); 92 | }; 93 | t.plan(nestedStateKeys.length + 1); 94 | createState(state, wrappedCreateNode); 95 | }); 96 | 97 | test('createTree node should combine and propogate child state', t => { 98 | const states = [{ 99 | obj: { 100 | foo: 1, 101 | bar: 'string', 102 | baz: true, 103 | qux: null 104 | } 105 | },{ 106 | obj: { 107 | foo: 2, 108 | bar: 'string', 109 | baz: true, 110 | qux: null 111 | } 112 | },{ 113 | obj: { 114 | foo: -1, 115 | bar: 'someOtherString', 116 | baz: null, 117 | qux: true 118 | } 119 | }]; 120 | 121 | const fooAction = new Rx.Subject(); 122 | const objAction = new Rx.Subject(); 123 | 124 | const node = createState(states[0]); 125 | 126 | t.plan(1); 127 | 128 | node.asObservable().take(3).toArray().subscribe((newStates) => { 129 | t.deepEqual(states, newStates) 130 | }); 131 | // combine 132 | node('obj.foo').reduce(fooAction, () => states[1].obj.foo); 133 | // propogate 134 | node('obj').reduce(objAction, () => states[2].obj); 135 | 136 | node.connect(); 137 | 138 | fooAction.onNext(1); 139 | objAction.onNext(2); 140 | }); 141 | 142 | test('separate hooks into a single action should lead to one update on parent', t => { 143 | const states = [{ 144 | a: 1, 145 | b: 2 146 | },{ 147 | a: 3, 148 | b: 4 149 | },{ 150 | a: 5, 151 | b: 6 152 | }]; 153 | 154 | const singleAction = new Rx.Subject(); 155 | 156 | const node = createState(states[0]); 157 | 158 | t.plan(1); 159 | 160 | node.asObservable().take(3).toArray().subscribe((newStates) => { 161 | t.deepEqual(states, newStates) 162 | }); 163 | 164 | node('a').reduce(singleAction, (state, i) => states[i].a); 165 | node('b').reduce(singleAction, (state, i) => states[i].b); 166 | 167 | node.connect(); 168 | 169 | singleAction.onNext(1); 170 | singleAction.onNext(2); 171 | }); 172 | 173 | test.cb('children should be pruned if excluded from reduced state', t => { 174 | const state = createState({ foo: 1, bar: 1 }); 175 | const pruneAction = createAction(); 176 | const testAction = createAction(); 177 | const pruneState = state('bar'); 178 | 179 | // The 2 is significant, if changed change 180 | // pruneState subscription 181 | pruneState.reduce(testAction, () => 2); 182 | 183 | pruneState.asObservable().subscribe((val) => { 184 | // Val will equal 2 if the subscription 185 | // is active when testAction is called. 186 | // Initial state of 'bar' is expected to 187 | // be not 2 188 | if (val === 2) { 189 | t.fail() 190 | } 191 | // completed will be called when a node is 192 | // pruned, all subscriptions to the node's 193 | // observable will be disposed as well on 194 | // the next tick. 195 | }, null, () => t.pass()); 196 | 197 | state.reduce(pruneAction, state => ({ foo: 1 })); 198 | 199 | state.connect(); 200 | 201 | t.plan(1); 202 | 203 | pruneAction(); 204 | 205 | // Test that subscriptions have been disposed 206 | setTimeout(() => testAction() || t.end()); 207 | }); 208 | 209 | test('reducers should be able to add children dynamically if in reduced state', t => { 210 | const state = createState({ foo: 1 }); 211 | const addAction = createAction(); 212 | 213 | state.reduce(addAction, () => ({ foo: 1, bar: 1, baz: 1 })); 214 | 215 | t.plan(2); 216 | 217 | // This will create a provisional node to be 218 | // populated by the reducer. 219 | state('bar') 220 | .asObservable() 221 | .subscribe(val => t.is(val, 1)); 222 | 223 | state.connect(); 224 | 225 | addAction(); 226 | 227 | // This node didn't have a provisional node, 228 | // but we should be able to access the one 229 | // created by reduce. 230 | state('baz') 231 | .asObservable() 232 | .subscribe(val => t.is(val, 1)); 233 | 234 | // Because no provisional node existed when 235 | // we connected the state, we have to connect 236 | // the new node here. 237 | state('baz').connect(); 238 | }); 239 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var webpack = require('webpack'); 4 | 5 | var config = { 6 | devtool: 'source-map', 7 | entry: './lib/index.js', 8 | module: { 9 | loaders: [ 10 | { test: /\.js$/, loaders: ['babel-loader'], exclude: /node_modules/ } 11 | ] 12 | }, 13 | output: { 14 | library: 'ReduRx', 15 | libraryTarget: 'umd', 16 | path: './browser', 17 | filename: 'redurx.min.js' 18 | }, 19 | plugins: [ 20 | new webpack.optimize.OccurrenceOrderPlugin(), 21 | new webpack.optimize.UglifyJsPlugin({ 22 | compressor: { 23 | pure_getters: true, 24 | unsafe: true, 25 | unsafe_comps: true, 26 | warnings: false 27 | } 28 | }) 29 | ] 30 | }; 31 | 32 | module.exports = config; 33 | --------------------------------------------------------------------------------