├── .npmignore ├── .gitignore ├── .babelrc ├── src ├── index.js ├── actions.js ├── StreamProvider.js ├── reducer.js ├── Slider.js ├── ActionViewer.js ├── TimelineUnit.js ├── actionStreams.js ├── reactiveComponent.js ├── Timeline.js └── omnistream.js ├── LICENSE ├── package.json ├── test └── omnistream.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | src/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createOmnistream from './omnistream.js'; 2 | import StreamProvider from './StreamProvider.js'; 3 | import {reactiveComponent} from './reactiveComponent.js'; 4 | import Timeline from './Timeline.js'; 5 | import createStatestream from './stateStream.js'; 6 | 7 | module.exports = { 8 | createOmnistream: createOmnistream, 9 | createStatestream: createStatestream, 10 | StreamProvider: StreamProvider, 11 | reactiveComponent: reactiveComponent, 12 | Timeline: Timeline, 13 | } -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | export const drag = (coordinate) => ( 2 | { type: 'BAR_MOVE', barPosition: coordinate, _ignore: true } 3 | ) 4 | 5 | export const stopDrag = () => { 6 | return { type: 'STOP_DRAG', _ignore: true } 7 | } 8 | 9 | export const startDrag = () => { 10 | return { type: 'START_DRAG', _ignore: true } 11 | } 12 | 13 | export const mouseLeave = () => ( 14 | { type: 'MOUSE_LEAVE', _ignore: true } 15 | ) 16 | 17 | export const updateView = (action) => { 18 | return { type: 'SELECT_ACTION', _ignore: true, action } 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/StreamProvider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | // Wrap the root component and allow all children to access omnistream through context. 4 | class StreamProvider extends Component { 5 | getChildContext() { 6 | return { omnistream: this.props.omnistream } 7 | } 8 | 9 | render() { 10 | return ( 11 |
12 | {this.props.children} 13 |
14 | ) 15 | } 16 | } 17 | 18 | export default StreamProvider; 19 | 20 | StreamProvider.childContextTypes = { 21 | omnistream: React.PropTypes.object.isRequired 22 | } 23 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | function dragReducer(state = { dragging: false }, action) { 2 | switch (action.type) { 3 | case 'STOP_DRAG': 4 | return Object.assign({}, state, { dragging: false }) 5 | case 'START_DRAG': 6 | return Object.assign({}, state, { dragging: true }) 7 | case 'SELECT_ACTION': 8 | return Object.assign({}, state, {action: action.action}); 9 | } 10 | return state; 11 | } 12 | 13 | function barPositionReducer(state = { barPosition: 10 }, action) { 14 | switch (action.type) { 15 | case 'BAR_MOVE': 16 | return Object.assign({}, state, { barPosition: action.barPosition }) 17 | } 18 | return state; 19 | } 20 | 21 | 22 | export { barPositionReducer, dragReducer }; -------------------------------------------------------------------------------- /src/Slider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { reactiveComponent } from './reactiveComponent.js'; 3 | import Rx from 'rxjs/Rx'; 4 | 5 | const STYLES = { 6 | width: '10px', 7 | height: '70px', 8 | backgroundColor: 'rgba(0, 97, 128, 0.682353)', 9 | position: 'relative', 10 | left: 10, 11 | zIndex: 1, 12 | } 13 | 14 | const Slider = (props) => { 15 | const {dragging, barPosition} = props; 16 | const zIndex = dragging ? -1 : 1; 17 | const updatedStyles = Object.assign({}, STYLES, { 18 | left: barPosition, 19 | zIndex: zIndex 20 | }); 21 | return ( 22 |
23 | ) 24 | } 25 | 26 | export default reactiveComponent(Slider, 'sliderState$', 'draggingState$'); -------------------------------------------------------------------------------- /src/ActionViewer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { reactiveComponent } from './reactiveComponent.js'; 4 | 5 | const STYLE = { 6 | 'wordWrap': 'break-word', 7 | 'border': '1px solid rgb(235, 235, 235)', 8 | 'padding': '5px', 9 | 'bottom': '80', 10 | 'position': 'relative', 11 | 'minHeight': '20px', 12 | 'maxWidth': '900px', 13 | 'fontSize': '1.15em', 14 | 'backgroundColor': '#fefefe', 15 | } 16 | 17 | function ActionViewer(props) { 18 | const {action} = props; 19 | return ( 20 |
21 | {JSON.stringify(action)} 22 |
23 | ) 24 | } 25 | 26 | export default reactiveComponent(ActionViewer, 'draggingState$'); -------------------------------------------------------------------------------- /src/TimelineUnit.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { reactiveComponent } from './reactiveComponent.js'; 4 | import Rx from 'rxjs/Rx'; 5 | import { updateView } from './actions.js'; 6 | 7 | const TimelineUnit = (props) => { 8 | const {dragging, index, timeTravel, styles, updateViewe, dispatch, node} = props; 9 | const handleMouseEnter = () => { 10 | dispatch(updateView(node)); 11 | dragging ? timeTravel(index) : undefined; 12 | } 13 | 14 | return ( 15 |
timeTravel(index)}> 18 | {index} 19 |
20 | ) 21 | } 22 | 23 | export default reactiveComponent(TimelineUnit, 'draggingState$'); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 soopjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/actionStreams.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Rx'; 2 | import { drag, stopDrag, startDrag } from './actions.js'; 3 | 4 | const click$ = Observable.fromEvent(document, 'click'); 5 | const sliderClick$ = click$.filter((e) => e.target.id === 'sliderBar'); 6 | const nonsliderClick$ = click$.filter((e) => (e.target.id !== 'sliderBar')); 7 | const mouseMove$ = Observable.fromEvent(document, 'mousemove'); 8 | 9 | const currentlyDragging = (omnistream) => omnistream.filter(x => x.type === 'MOUSE_LEAVE') 10 | .map(x => 'stop') 11 | .merge(sliderClick$.map(x => 'slider')) 12 | .merge(nonsliderClick$.map(x => 'nonslider')) 13 | .scan((dragging, val) => { 14 | if (val === 'slider') return !dragging; 15 | if (val === 'nonslider' || val === 'stop') return false; 16 | }, false) 17 | .map(dragging => dragging ? startDrag() : stopDrag()) 18 | 19 | const dragMovement = (omnistream) => { 20 | return omnistream.filterForActionTypes('STOP_DRAG', 'START_DRAG') 21 | .switchMap((dragging) => { 22 | return dragging.type === 'STOP_DRAG' ? Observable.never() : mouseMove$ 23 | }) 24 | .map(e => drag(e.pageX)) 25 | }; 26 | 27 | export { dragMovement, currentlyDragging }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "omnistream", 3 | "version": "0.1.4", 4 | "description": "Stream based state management for React built on RxJs", 5 | "main": "./dist/", 6 | "scripts": { 7 | "test": "ava", 8 | "build": "babel src --out-dir dist", 9 | "build:pack": "babel src --out-dir dist && npm pack" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/soup-js/omnistream.git" 14 | }, 15 | "author": "", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/soup-js/omnistream/issues" 19 | }, 20 | "homepage": "https://github.com/soup-js/omnistream#readme", 21 | "dependencies": { 22 | "react": "^15.4.1", 23 | "react-dom": "^15.4.1", 24 | "rxjs": "^5.0.0-rc.4" 25 | }, 26 | "devDependencies": { 27 | "ava": "^0.17.0", 28 | "babel-core": "^6.18.2", 29 | "babel-loader": "^6.2.8", 30 | "babel-preset-es2015": "^6.18.0", 31 | "babel-preset-react": "^6.16.0", 32 | "babel-tape-runner": "^2.0.1", 33 | "mocha": "^3.2.0", 34 | "tap-spec": "^4.1.1", 35 | "tape": "^4.6.3", 36 | "webpack": "^1.13.3", 37 | "webpack-dev-server": "^1.16.2" 38 | }, 39 | "ava": { 40 | "babel": { 41 | "presets": [ 42 | "es2015" 43 | ] 44 | }, 45 | "require": ["babel-register"] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/reactiveComponent.js: -------------------------------------------------------------------------------- 1 | const Rx = require('rxjs/Rx'); 2 | import React, { PureComponent } from 'react'; 3 | 4 | // mapStreamsToProps creates an observable stream for each action type given in "streamNames". 5 | // This stream emits objects that will be passed down as props to the reactive components. 6 | const combineStreamsToState = (stateStreams) => { 7 | return Rx.Observable.combineLatest(...stateStreams, (...stateData) => { 8 | return stateData.reduce((accum, curr) => { 9 | return Object.assign(accum, curr) 10 | }, {}) 11 | }).distinctUntilChanged(null, state => JSON.stringify(state)); 12 | } 13 | 14 | //ReactiveComponent subscribes to a stream and re-renders when it receives new data. 15 | function makeReactive(componentDefinition, renderFn, ...stateStreamNames) { 16 | class ReactiveComponent extends PureComponent { 17 | constructor(props, context) { 18 | super(props, context); 19 | this.state = { childProps: {} } 20 | this.omnistream = this.context.omnistream; 21 | 22 | // Make the dispatch function accessible to be passed as a prop to child components. 23 | this.dispatch = this.omnistream.dispatch.bind(context.omnistream); 24 | this.dispatchObservableFn = this.omnistream.dispatchObservableFn.bind(context.omnistream); 25 | } 26 | 27 | componentDidMount() { 28 | // Creates a new substream for each action type based on the provided "streamNames" 29 | const stateStreams = stateStreamNames.map(name => this.omnistream.store[name]); 30 | const state$ = combineStreamsToState(stateStreams); 31 | // Subscribes to the props stream. This will trigger a re-render whenever a new action has been dispatched to 32 | // any filtered stream passed down as props to a component. 33 | this.subscription = state$.subscribe((props) => { 34 | this.setState({ childProps: Object.assign({}, this.props, props) }); 35 | }); 36 | } 37 | 38 | componentWillUnmount() { 39 | this.subscription.unsubscribe(); 40 | } 41 | 42 | render() { 43 | return renderFn.call(this, componentDefinition); 44 | } 45 | } 46 | ReactiveComponent.contextTypes = { omnistream: React.PropTypes.object.isRequired } 47 | return ReactiveComponent; 48 | } 49 | 50 | function renderTimeline(componentDefinition) { 51 | return React.createElement(componentDefinition, 52 | Object.assign({}, this.state.childProps, { 53 | dispatch: this.dispatch, 54 | dispatchObservableFn: this.dispatchObservableFn, 55 | omnistream: this.omnistream 56 | }, this.props), null) 57 | } 58 | 59 | function renderStandard(componentDefinition) { 60 | return React.createElement(componentDefinition, 61 | Object.assign({}, this.state.childProps, { 62 | dispatch: this.dispatch, 63 | dispatchObservableFn: this.dispatchObservableFn, 64 | }, this.props), null) 65 | } 66 | 67 | export const reactiveComponent = (componentDefinition, ...stateStreamNames) => 68 | makeReactive(componentDefinition, renderStandard, ...stateStreamNames) 69 | 70 | export const reactiveTimeline = (componentDefinition, ...stateStreamNames) => 71 | makeReactive(componentDefinition, renderTimeline, ...stateStreamNames) -------------------------------------------------------------------------------- /src/Timeline.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Rx from 'rxjs/Rx'; 3 | import Slider from './Slider'; 4 | import TimelineUnit from './TimelineUnit'; 5 | import createOmnistream from './omnistream.js'; 6 | import ActionViewer from './ActionViewer.js'; 7 | import { reactiveTimeline } from './reactiveComponent.js'; 8 | import { dragMovement, currentlyDragging } from './actionStreams.js'; 9 | import { dragReducer, barPositionReducer } from './reducer.js'; 10 | import { stopDrag, mouseLeave } from './actions'; 11 | 12 | const STYLES = { 13 | position: 'fixed', 14 | backgroundColor: '#f4f4f4', 15 | overflowX: 'scroll', 16 | overflowY: 'hidden', 17 | whiteSpace: 'nowrap', 18 | width: '100%', 19 | height: '70px', 20 | bottom: '0px', 21 | borderTop: '1px solid #b0b0b0' 22 | } 23 | 24 | const MAIN_CONTAINER_STYLES = { 25 | fontFamily: 'monospace', 26 | position: 'fixed', 27 | display: 'flex', 28 | justifyContent: 'center', 29 | width: '100%', 30 | bottom: '0', 31 | } 32 | 33 | const UNIT_STYLES = { 34 | display: 'inline-block', 35 | zIndex: 0, 36 | height: '70px', 37 | marginTop: '-30px', 38 | borderLeft: '1px solid #909090', 39 | width: '24px', 40 | textAlign: 'center', 41 | lineHeight: '70px', 42 | marginLeft: '5px' 43 | } 44 | 45 | const CONTAINER_STYLE = { 46 | fontWeight: '200', 47 | fontSize: '.75em', 48 | position: 'relative', 49 | bottom: '40px', 50 | } 51 | 52 | 53 | const draggingStateFn = (omnistream) => { 54 | return omnistream.filterForActionTypes(['START_DRAG', 'STOP_DRAG', 'SELECT_ACTION']) 55 | } 56 | 57 | // setup OMNISTREAMS 58 | const addTimelinestore = (omnistream) => { 59 | const sliderState$ = omnistream._createTimelineStatestream(barPositionReducer, dragMovement); 60 | const draggingState$ = omnistream._createTimelineStatestream(dragReducer, draggingStateFn); 61 | omnistream.addToStore({ sliderState$, draggingState$ }); 62 | } 63 | 64 | 65 | class Timeline extends Component { 66 | constructor(props) { 67 | super(props); 68 | this.omnistream = this.props.omnistream; 69 | addTimelinestore(this.omnistream); 70 | this.state = { history: [] }; 71 | this.history$ = this.omnistream.history$; 72 | this.timeTravelToPointN = this.omnistream.timeTravelToPointN.bind(this.omnistream); 73 | } 74 | 75 | componentDidMount() { 76 | this.history$.subscribe((historyArray) => { 77 | this.setState({ history: historyArray }); 78 | }) 79 | this.props.dispatchObservableFn(currentlyDragging); 80 | this.listener = document.getElementById('timeline').addEventListener('mouseleave', (x) => { 81 | this.props.dispatch(mouseLeave()); 82 | }); 83 | } 84 | 85 | compomentWillUnmount() { 86 | document.getElementById('timeline').removeEventListener(this.listener); 87 | } 88 | 89 | render() { 90 | const units = this.state.history.map((node, index) => { 91 | return 92 | }) 93 | return ( 94 |
95 | 96 |
97 | 98 |
99 | {units} 100 |
101 |
102 |
103 | ) 104 | } 105 | } 106 | 107 | export default reactiveTimeline(Timeline); -------------------------------------------------------------------------------- /src/omnistream.js: -------------------------------------------------------------------------------- 1 | const Rx = require('rxjs/Rx'); 2 | 3 | class Omnistream { 4 | // Instatiates a new stream to manage state for the application 5 | constructor() { 6 | this.stream = new Rx.BehaviorSubject(); 7 | // Creates an array to hold all actions dispatched within an application. 8 | // This feature allows for time travel debugging in O(n) space. 9 | this.history = []; 10 | this.timeTravelEnabled = false; 11 | this.history$ = this.getHistory(); 12 | this.store = { 'omniHistory$': this.history$ }; 13 | } 14 | 15 | // make it so actions are not dispatched if currently dragging timeline UNLESS they have an ignore property 16 | 17 | 18 | // Creates a state-stream with provided reducer and action stream 19 | createStatestream(reducer, actionStream = this.stream) { 20 | return actionStream(this) 21 | .merge(this.stream.filter(value => value ? value._clearState : false)) 22 | .startWith(reducer(undefined, { type: null })) 23 | .scan((acc, curr) => ( 24 | curr._clearState ? reducer(undefined, { type: null }) : reducer(acc, curr) 25 | )) 26 | } 27 | 28 | _createTimelineStatestream(reducer, actionStream) { 29 | return actionStream(this) 30 | .merge(this.stream.filter(value => value ? value._clearState : false)) 31 | .startWith(reducer(undefined, { type: null })) 32 | .scan(reducer) 33 | } 34 | 35 | // Creates a collection of all state-streams 36 | createStore(streamCollection) { 37 | this.store = streamCollection; 38 | } 39 | 40 | addToStore(streamCollection) { 41 | this.store = Object.assign({}, this.store, streamCollection); 42 | } 43 | 44 | // Check whether each action dispatched has data and type properties. 45 | // If so, pass the action to the omnistream. 46 | dispatch(action) { 47 | if (!(action.hasOwnProperty('type') && !(action._clearState))) { 48 | throw new Error('Actions dispatched to omnistream must be objects with type properties') 49 | } 50 | if (this.timeTravelEnabled && action._ignore) this.stream.next(action); 51 | else if (!this.timeTravelEnabled) this.stream.next(action); 52 | } 53 | // Dispatch an observable to the omnistream 54 | dispatchObservableFn(streamFunction) { 55 | const sideEffectStream = streamFunction(this.stream.filter(action => action).skip(1)); 56 | sideEffectStream.subscribe((action) => { 57 | this.dispatch(action); 58 | }) 59 | } 60 | 61 | // Create an observable of data for a specific action type. 62 | filterForActionTypes(...actionTypes) { 63 | const actions = Array.isArray(actionTypes[0]) ? actionTypes[0] : actionTypes; 64 | const hash = actions.reduce((acc, curr) => Object.assign(acc, { [curr]: true }), {}); 65 | return this.stream.filter(action => { 66 | return action ? (hash[action.type]) : false 67 | }) 68 | } 69 | 70 | // Create an observable that updates history when a new action is received. 71 | getHistory() { 72 | const history$ = this.stream.filter(action => action && !action._ignore) 73 | .scan((acc, cur) => { 74 | acc.push(cur); 75 | return acc; 76 | }, []) 77 | .publish() 78 | history$.connect(); 79 | history$.subscribe(el => this.history = el) 80 | 81 | // ignore side effects during time travel 82 | const enableTimeTravel$ = this.stream 83 | .filter(action => action ? (action.type === 'START_DRAG' || action.type === 'STOP_DRAG') : false) 84 | .map(action => action.type) 85 | enableTimeTravel$.subscribe(val => { 86 | this.timeTravelEnabled = (val === 'START_DRAG') ? true : false 87 | }) 88 | return history$ 89 | } 90 | 91 | // Revert the app back to its original state 92 | clearState() { 93 | this.stream.next({ _clearState: true, _ignore: true }); 94 | } 95 | 96 | timeTravelToPointN(n) { 97 | this.clearState(); 98 | for (let i = 0; i <= n; i++) { 99 | if (!(this.history[i].sideEffect)) { 100 | this.dispatch(Object.assign({ _ignore: true }, this.history[i])); 101 | } 102 | } 103 | } 104 | } 105 | 106 | export default function createOmnistream() { 107 | return new Omnistream(); 108 | } 109 | -------------------------------------------------------------------------------- /test/omnistream.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import createOmnistream from '../src/omnistream.js'; 3 | import Rx from 'rxjs/rx'; 4 | 5 | const omnistream = createOmnistream(); 6 | test('omnistream has a behavior subject stream', (t) => { 7 | t.plan(1); 8 | t.true(omnistream.stream instanceof Rx.BehaviorSubject); 9 | }); 10 | 11 | test('omnistream has history$ observable', (t) => { 12 | t.plan(2); 13 | t.true(omnistream.hasOwnProperty('history$')); 14 | t.true(omnistream.history$ instanceof Rx.Observable); 15 | }); 16 | 17 | test('omnistream has store object with omniHistory$ observable', (t) => { 18 | t.plan(3); 19 | t.true(omnistream.hasOwnProperty('store')); 20 | t.true(typeof omnistream.store === 'object'); 21 | t.true(omnistream.store.omniHistory$ instanceof Rx.Observable); 22 | }); 23 | 24 | test('clearState emits action with _clearState and _ignore properties', (t) => { 25 | t.plan(2); 26 | const omnistream = createOmnistream(); 27 | omnistream.clearState(); 28 | return omnistream.stream.take(1).map((action) => { 29 | t.true(action._clearState); 30 | t.true(action._ignore); 31 | }) 32 | }) 33 | 34 | test('dispatch emits actions to omnistream', (t) => { 35 | t.plan(1); 36 | const omnistream = createOmnistream(); 37 | omnistream.dispatch({ type: 'test' }); 38 | return omnistream.stream.take(1).map((action) => { 39 | t.deepEqual({ type: 'test' }, action); 40 | }) 41 | }) 42 | 43 | test('dispatch throws error if no type provided', (t) => { 44 | t.plan(1); 45 | const omnistream = createOmnistream(); 46 | t.throws(() => omnistream.dispatch({ notType: 'test' }), 47 | 'Actions dispatched to superstream must be objects with type properties') 48 | }) 49 | 50 | test('createStore creates a store property', (t) => { 51 | t.plan(1); 52 | omnistream.createStore({ testStore$: Rx.Observable.never() }); 53 | t.true(omnistream.hasOwnProperty('store')); 54 | }) 55 | 56 | test('get history returns a subject', (t) => { 57 | t.plan(1); 58 | const omnistream = createOmnistream(); 59 | const history$ = omnistream.getHistory(); 60 | t.true(history$ instanceof Rx.Subject); 61 | }) 62 | 63 | test('subscription to history$ returns y', (t) => { 64 | t.plan(2); 65 | const omnistream = createOmnistream(); 66 | const history$ = omnistream.getHistory(); 67 | omnistream.dispatch({ type: 'A' }) 68 | t.deepEqual(omnistream.history, [{type: 'A'}]) 69 | omnistream.dispatch({ type: 'B' }) 70 | t.deepEqual(omnistream.history, [{type: 'A'}, {type: 'B'}]) 71 | }) 72 | 73 | test('history$ does not record actions with _ignore property', (t) => { 74 | t.plan(1); 75 | const omnistream = createOmnistream(); 76 | const history$ = omnistream.getHistory(); 77 | omnistream.dispatch({ type: 'A' }) 78 | omnistream.dispatch({ type: 'B', _ignore: true }) 79 | t.deepEqual(omnistream.history, [{type: 'A'}]) 80 | }) 81 | 82 | test('filterForActionTypes only outputs actions according to the parameters passed in', (t) => { 83 | t.plan(1); 84 | const omnistream = createOmnistream(); 85 | const filteredStream$ = omnistream.filterForActionTypes('FIRST_ACTION'); 86 | filteredStream$.subscribe((el) =>{ 87 | t.deepEqual({ type: 'FIRST_ACTION' }, el) 88 | }) 89 | omnistream.dispatch({ type: 'SECOND_ACTION' } ) 90 | omnistream.dispatch({ type: 'FIRST_ACTION' }) 91 | }) 92 | 93 | test('filterForActionTypes takes multiple inputs', (t) => { 94 | t.plan(1); 95 | const omnistream = createOmnistream(); 96 | const filteredStream$ = omnistream.filterForActionTypes('FIRST_ACTION', 'SECOND_ACTION'); 97 | const actions = []; 98 | filteredStream$.subscribe(el => actions.push(el)); 99 | omnistream.dispatch({ type: 'SECOND_ACTION' } ) 100 | omnistream.dispatch({ type: 'FIRST_ACTION' }) 101 | omnistream.dispatch({ type: 'THIRD_ACTION' }) 102 | t.deepEqual(actions, [{ type: 'SECOND_ACTION' },{ type: 'FIRST_ACTION' }]) 103 | }) 104 | 105 | test('filterForActionTypes takes multiple inputs as an array', (t) => { 106 | t.plan(1); 107 | const omnistream = createOmnistream(); 108 | const filteredStream$ = omnistream.filterForActionTypes(['FIRST_ACTION', 'SECOND_ACTION']); 109 | const actions = []; 110 | filteredStream$.subscribe(el => actions.push(el)); 111 | omnistream.dispatch({ type: 'SECOND_ACTION' } ) 112 | omnistream.dispatch({ type: 'FIRST_ACTION' }) 113 | omnistream.dispatch({ type: 'THIRD_ACTION' }) 114 | t.deepEqual(actions, [{ type: 'SECOND_ACTION' },{ type: 'FIRST_ACTION' }]) 115 | }) 116 | 117 | test('dispatchObservableFn dispatches values from returned observable', (t) => { 118 | t.plan(1); 119 | const testObservable = () => Rx.Observable.of({type: 'ACTION'}); 120 | const omnistream = createOmnistream(); 121 | omnistream.stream.subscribe(element => { 122 | if (element) t.deepEqual(element, {type: 'ACTION'}) 123 | }); 124 | omnistream.dispatchObservableFn(testObservable); 125 | }) 126 | 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Omnistream 2 | ### Omnistream is a stream-based state management library for React built on RxJs observables. 3 | 4 | Omnistream has a simple API that allows React components to selectively subscribe to portions of a central store. This avoids unnecessary re-renders without the need of `shouldComponentUpdate` or other workarounds. Upon connecting, your components will always stay up to date with the store and re-render as needed. With this model, it's possible to work exclusively with stateless functional components, creating a more reactive application structure. Omnistream also features a built-in time-travelling debugger that operates without keeping any copies of the application state (since in Omnistream, the store is a stream of actions). 5 | 6 | In the spirit of [redux-observable](https://github.com/redux-observable/redux-observable), Omnistream is built around the idea of dispatching observables to your store. This allows you to compose some complicated async logic fairly easily. 7 | 8 | ## Disclaimer 9 | 10 | Omnistream is in early stages of development and all features are currently experimental. We would appreciate hearing about any issues you encounter or feature requests. Feel free to open issues or submit pull requests. 11 | 12 | ## Getting Started 13 | ---- 14 | 15 | `npm install --save omnistream` 16 | 17 | ### Create a central store 18 | 19 | The central store is called the omnistream. After creating it and adding state streams, wrap your components in the provided StreamProvider component to give them access to it. 20 | 21 | ``` javascript 22 | 23 | import React from 'react' 24 | import ReactDOM from 'react-dom' 25 | import { createOmnistream, StreamProvider } from 'Omnistream' 26 | import { loginAction } from './actionStreams'; // import action stream creator 27 | import { loginReducer } from './reducers'; 28 | 29 | const omnistream = createOmnistream(); 30 | const loginState$ = omnistream.createStatestream(loginReducer, loginAction); // create a state stream 31 | omnistream.createStore({ loginState$ }); 32 | 33 | ReactDOM.render( 34 | // wrap components in StreamProvider to give them access to the omnistream 35 | // pass the omnistream instance to the StreamProvider 36 | 37 | , 38 | document.getElementById('root') 39 | ); 40 | ``` 41 | 42 | ### Creating a collection of state streams 43 | 44 | Omnistream stores state in a collection of "state streams." This is a simple object with keys representing different streams. Each of these streams emit objects which hold the current state for its subscriptions. With each new relevant action, a reducer will reduce a new copy of state, and this new copy will be pushed to subscribers. 45 | 46 | To set up state streams, there are three steps: 47 | 48 | 1. Write reducers 49 | 50 | 2. Call `omnistream.createStatestream` with a reducer and optional function to create a custom action stream 51 | 52 | 3. Combine state streams into the omnistream store 53 | 54 | If the second argument to `createStatestream` is not provided, the resulting state stream will send all dispatched actions to its reducer. We recommend filtering the omnistream to only the relevant actions required by the reducer. This can be done with the `omnistream.filterForActionTypes` method, which takes an array of action types (or multiple parameters specifying the action types), and returns a filtered action stream according to those actions. To use the second argument to `createStatestream`, provide a function that takes the omnistream as an argument and applies the `filterForActionTypes` method. 55 | 56 | Once state streams have been created, they can be added to the omnistream's store with the `omnistream.createStore` method. Note that this method overwrites the current store if it exists. To instead add a state stream to the store after it has been created, use `omnistream.addToStore`. 57 | 58 | 59 | Creating an action stream and state stream: 60 | 61 | ```javascript 62 | const omnistream = omnistream.createOmnistream(); 63 | const loginAction = (omnisteam) => omnistream.filterForActionTypes('USER_LOGIN'); // creates login action stream 64 | const loginState$ = omnistream.createStatestream(loginReducer, loginAction); // creates login state stream 65 | omnistream.createStore({ loginState$, ...otherStates$ }); 66 | ``` 67 | 68 | ### Connecting a component 69 | 70 | To connect a component to the omnistream, wrap your component in a call to the provided `reactiveComponent` method, along with strings specifying the specific streams you'd like to subscribe to. 71 | 72 | ```javascript 73 | function User(props) { 74 | // destructure the props received from stream subscriptions 75 | const {username, url} = props; 76 | return ( 77 |
78 |

{username}

79 | 80 |
81 | ) 82 | } 83 | export default reactiveComponent(User, 'loginState$'); 84 | ``` 85 | 86 | In the above example, the component will be subscribed to `loginState$`, and all new copies of that state will be pushed to the component in its props. 87 | 88 | ### Dispatching actions 89 | 90 | Omnistream provides a `dispatch` method to all Reactive Components as part of their props. To dispatch an action, simply call dispatch with an object containing a `type` property. 91 | 92 | ```javascript 93 | function User(props) { 94 | const {username, url, dispatch} = props; 95 | return ( 96 |
97 | dispatch({data: e.target.value, type: 'USER_NAME'})} /> 98 |

{username}

99 | 100 |
101 | ) 102 | } 103 | 104 | export default reactiveComponent(User, 'loginState$'); 105 | ``` 106 | 107 | Here, an action of the form `{data: e.target.value, type: 'USER_NAME'}` is dispatched. When this action is dispatched, it is merged into the omnistream. Any updates from the `loginState$` will be pushed to this component in the form of props. 108 | 109 | ### Dispatching observables 110 | 111 | Omnistream also provides `omnistream.dispatchObservableFn` as a method to dispatch observables instead of simple actions. The observables can then emit their own streams of actions, which will be folded into the omnistream in the correct order. This allows one to design complex asynchronous action sequences. Furthermore, every dispatched observable will have access to `omnistream`, so you can create observables that interact with actions dispatched from separate parts of the app. 112 | 113 | `dispatchObservableFn` takes one argument, which should be a function that returns an observable. The observable function you provide will be passed the omnistream's action stream as its first parameter. 114 | 115 | ```javascript 116 | const timeUntilLogin = (omnistream) => ( 117 | const login = omnistream.filter(action => action.type === 'USER_LOGIN'); 118 | return Rx.Observable.interval(100) 119 | .scan((acc, curr) => acc + 100, 0) 120 | .map(ms => ({type: 'TIME_TO_LOGIN', ms})) 121 | .takeUntil(login); 122 | ) 123 | 124 | class User extends Component() { 125 | componentDidMount() { 126 | this.props.dispatchObservableFn(timeUntilLogin); 127 | } 128 | 129 | render() { 130 | return ( 131 |
132 | dispatch({data: e.target.value, type: 'USER_NAME'})} /> 133 |

{username}

134 | 135 |
136 | ) 137 | } 138 | } 139 | 140 | export default reactiveComponent(User, 'loginState$'); 141 | ``` 142 | 143 | In this example, an observable is dispatched which will record the time up until a login action is sent. This is possible because `omnistream` is passed in as the first argument to the `timeUntilLogin` function. When the component mounts, it dispatches this function to the omnistream which evaluates it and folds in all resulting actions. 144 | 145 | 146 | ### Adding the timeline 147 | 148 | Adding the timeline debugger is simply a matter of including the provided `Timeline` component in your app, along with the omnistream as a prop. 149 | 150 | ```javascript 151 | import React from 'react' 152 | import ReactDOM from 'react-dom' 153 | import { createOmnistream, StreamProvider } from 'Omnistream' 154 | import {loginAction} from './actionStreams'; 155 | 156 | const omnistream = createOmnistream(); 157 | const loginState$ = omnistream.createStatestream(barPositionReducer, loginAction); 158 | omnistream.createStore({ loginState$ }); // create the store 159 | 160 | ReactDOM.render( 161 |
162 | 163 | 164 | 165 | // add the timeline component with omnistream as a prop 166 | 167 |
, 168 | document.getElementById('root') 169 | ); 170 | ``` 171 | 172 | When your app is rendered, it will now include a timeline with a visualization of every action in your app. Clicking on the slider will enable time travel, and dragging it to different actions will revert the app to that particular point. Side effects are ignored during time travel, so you don't need to worry about `componentDidMount` AJAX calls or similar events polluting the timeline. 173 | 174 | Double clicking on any action displayed in the timeline will revert the app to it's state upon receiving that action, and hovering over any action will display its actual javascript object representation. 175 | 176 | ![timetravel](https://cloud.githubusercontent.com/assets/14319917/21365906/4f9f49bc-c6ac-11e6-915e-b076265523a9.gif) 177 | --------------------------------------------------------------------------------