├── .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 | 
2 |
3 | [Travis CI Status](https://travis-ci.org/shiftyp/redurx): 
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 | 
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 |
354 | {list.map(todo => {todo} )}
355 |
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 | 
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 | Increment if odd
14 | Increment async
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 |
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 |
20 |
21 | {showMore}
22 |
26 |
27 | {list.map(message => )}
28 |
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 |
11 |
12 | {` Messages`} {messages.totalUnread}
13 |
17 |
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 |
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 |
Increment
21 |
Decrement
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 |
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 |
10 | {list.map(({ id, text }) => (
11 |
12 |
14 | {text}
15 |
16 |
17 | ))}
18 |
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 |
50 | Clear completed
51 |
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 |
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 |
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 | {
23 | actions.editTodo(todo);
24 | }}
25 | >
26 | {todo.text}
27 |
28 | actions.deleteTodo(todo.id)}
31 | />
32 |
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 |
--------------------------------------------------------------------------------