├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── example ├── .hz │ └── config.toml ├── dist │ ├── index.html │ └── js │ │ └── bundle.js └── src │ ├── components │ ├── chat.js │ ├── input.js │ ├── list.js │ └── message.js │ └── index.js ├── jsconfig.json ├── logo.png ├── package.json ├── schema.png ├── src ├── connect.js ├── index.js ├── provider.js └── route.js └── test ├── .setup.js ├── connect.spec.js ├── provider.spec.js ├── route.spec.js ├── utils.js └── withQueries.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["airbnb", "stage-1"], 3 | "plugins": [ 4 | "transform-decorators-legacy" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | example/rethinkdb_data 3 | build 4 | *.tgz -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | example/ 3 | test/ 4 | *.png 5 | .npmignore 6 | .babelrc 7 | .travis.yml 8 | jsconfig.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "5" 5 | before_install: 6 | - npm install @horizon/client react 7 | before_script: 8 | - npm run build 9 | branches: 10 | only: 11 | - master 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Roman Liutikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/react-hz.svg?style=flat-square)](https://www.npmjs.com/package/react-hz) 2 | [![Build Status](https://img.shields.io/travis/roman01la/react-horizon/master.svg?style=flat-square)](https://travis-ci.org/roman01la/react-horizon) 3 | 4 | # React Horizon 5 | 6 | logo 7 | 8 | *React Horizon makes it easier to use your React application with horizon.io realtime backend* 9 | 10 | ## Installation 11 | ``` 12 | $ npm i react-hz 13 | ``` 14 | 15 | React Horizon allows reactive dataflow between backend and React.js application. Client demand is declared in React components using Horizon's query API and data is synchronized thanks to [horizon.io](http://horizon.io/) realtime backend. 16 | 17 | Dataflow schema 18 | 19 | ## Running example 20 | - Make sure you have installed RethinkDB and Horizon's CLI 21 | - Start server from `example` directory: `$ hz serve --dev` 22 | - Open http://127.0.0.1:8181 in your browser 23 | 24 | ## Usage 25 | 26 | Read Horizon's [Collection API](http://horizon.io/api/collection/) for querying methods. 27 | 28 | `react-hz` package provides `HorizonProvider` instance provider component, `HorizonRoute` application route component, connector function and `Horizon` client library. 29 | 30 | ### `` 31 | `HorizonProvider` is a top level component in your application which establishes connection to Horizon server. The component accepts an instance of `Horizon` constructor as `instance` prop. 32 | ```js 33 | 34 | 35 | 36 | ``` 37 | 38 | ### `Horizon([config])` 39 | `Horizon` is a constructor function from Horizon's client library included into `react-hz`. Constructor function accepts optional config object http://horizon.io/api/horizon/#constructor. 40 | ```js 41 | const horizonInstance = Horizon({ host: 'localhost:8181' }); 42 | ``` 43 | 44 | ### `` 45 | `HorizonRoute` is a top level component for every screen in your application which provides an API to respond to connectivity status changes. 46 | Normally you should render your app in `renderSuccess` callback. `renderFailure` callback receives error object which can be used to render an error message. 47 | ```js 48 |

Connecting...

} 50 | renderDisconnected={() =>

You are offline

} 51 | renderConnected={() =>

You are online

} 52 | renderSuccess={() =>

Hello!

} 53 | renderFailure={(error) =>

Something went wrong...

} /> 54 | ``` 55 | 56 | ### `connect(component, config)` 57 | `connect` function wraps React components with specified queries for subscriptions and mutations. Connector function expects two arguments: React component and subscriptions/mutations config object. Props passed into container component are automatically passed into wrapped component. 58 | ```js 59 | const AppContainer = connect(App, { 60 | subscriptions: { 61 | // ... 62 | }, 63 | mutations: { 64 | // ... 65 | } 66 | }); 67 | ``` 68 | 69 | ### `withQueries(config)` 70 | `withQueries` is like `connect`, but designed to be used as a decorator. If you have enabled the decorator syntax in your project, instead of using `connect` like above, you can do the following: 71 | ```js 72 | 73 | import {withQueries} from 'react-hz' 74 | 75 | @withQueries({ 76 | subscriptions: { 77 | // ... 78 | }, 79 | mutations: { 80 | // ... 81 | } 82 | }) 83 | class MyComponent extends Component { 84 | // ... 85 | } 86 | ``` 87 | 88 | 89 | 90 | ### Subscriptions 91 | 92 | `subscriptions` is a map of subscription names to query functions. Data behind query is available as a prop with the same name in React component. Query function receives Horizon `hz` function which should be used to construct a query using Horizon's Collection API and props object which is being passed into container component. 93 | 94 | Behind the scenes React Horizon calls `watch` and `subscribe` function on query object which returns RxJS Observable and subscribes to incoming data. Data received by that observable is then passed into React component as props. 95 | 96 | All subscriptions are unsubscribed automatically on `componentWillUnmount`. 97 | 98 | ```js 99 | import React, { Component } from 'react'; 100 | import { render } from 'react-dom'; 101 | import { Horizon, HorizonProvider, connect } from 'react-hz'; 102 | 103 | class App extends Component { 104 | render() { 105 | 106 | const itemsSubcription = this.props.items; 107 | 108 | return ( 109 |
    {itemsSubcription.map(({ id, title }) =>
  • {title}
  • )}
110 | ); 111 | } 112 | } 113 | 114 | const AppContainer = connect(App, { 115 | subscriptions: { 116 | items: (hz, { username }) => hz('items') 117 | .find({ username }) 118 | .below({ id: 10 }) 119 | .order('title', 'ascending') 120 | } 121 | }); 122 | 123 | render(( 124 | 125 | 126 | 127 | ), document.getElementById('app')); 128 | ``` 129 | 130 | ### Mutations 131 | 132 | `mutations` is a map of mutation query names to mutation query functions. Specified mutations are available as props in React component behind their corresponding names in config. 133 | 134 | Available mutation operations: 135 | - `remove` - http://horizon.io/api/collection/#remove 136 | - `removeAll` - http://horizon.io/api/collection/#removeall 137 | - `replace` - http://horizon.io/api/collection/#replace 138 | - `store` - http://horizon.io/api/collection/#store 139 | - `upsert` - http://horizon.io/api/collection/#upsert 140 | 141 | It's possible to create two types of mutations (see example below): 142 | - generic mutation which provides mutation object and thus gives you an ability to call different mutation operations in component 143 | - specific mutation which is a function that receives parameters required for mutation, instantiates mutations object and applies mutation immediately 144 | 145 | ```js 146 | import React, { Component } from 'react'; 147 | import { render } from 'react-dom'; 148 | import { Horizon, HorizonProvider, connect } from 'react-hz'; 149 | 150 | class App extends Component { 151 | render() { 152 | 153 | const itemsMutation = this.props.items; 154 | const removeItem = this.props.removeItem; 155 | 156 | return ( 157 |
158 | 159 | 160 |
161 | ); 162 | } 163 | } 164 | 165 | const AppContainer = connectHorizon(App, { 166 | mutations: { 167 | items: (hz) => hz('items'), 168 | removeItem: (hz) => (id) => hz('items').remove(id) 169 | } 170 | }); 171 | 172 | render(( 173 | 174 | 175 | 176 | ), document.getElementById('app')); 177 | ``` 178 | 179 | ## Limitations 180 | 181 | - **GraphQL**. GraphQL would be a much better declarative replacement instead of current Collection API. Horizon team is working on GraphQL adapter, follow [this thread](https://github.com/rethinkdb/horizon/issues/125) for updates. 182 | - **Optimistic updates**. Optimistic updates feature is [being discussed](https://github.com/rethinkdb/horizon/issues/23) and it seems like it's not obvious at the moment if this should be baked into client library. 183 | - **Offline**. [Offline support is not implemented yet](https://github.com/rethinkdb/horizon/issues/58). 184 | - **Managing reconnection**. [Automatic reconnection is not implemented yet](https://github.com/rethinkdb/horizon/issues/9). 185 | 186 | MIT 187 | -------------------------------------------------------------------------------- /example/.hz/config.toml: -------------------------------------------------------------------------------- 1 | # This is a TOML file 2 | 3 | ############################################################################### 4 | # IP options 5 | # 'bind' controls which local interfaces will be listened on 6 | # 'port' controls which port will be listened on 7 | #------------------------------------------------------------------------------ 8 | # bind = [ "localhost" ] 9 | # port = 8181 10 | 11 | 12 | ############################################################################### 13 | # HTTPS Options 14 | # 'secure' will disable HTTPS and use HTTP instead when set to 'false' 15 | # 'key_file' and 'cert_file' are required for serving HTTPS 16 | #------------------------------------------------------------------------------ 17 | # secure = false 18 | # key_file = "horizon-key.pem" 19 | # cert_file = "horizon-cert.pem" 20 | 21 | 22 | ############################################################################### 23 | # App Options 24 | # 'project_name' sets the name of the RethinkDB database used to store the 25 | # application state 26 | # 'serve_static' will serve files from the given directory over HTTP/HTTPS 27 | #------------------------------------------------------------------------------ 28 | project_name = "example" 29 | # serve_static = "dist" 30 | 31 | 32 | ############################################################################### 33 | # Data Options 34 | # WARNING: these should probably not be enabled on a publically accessible 35 | # service. Tables and indexes are not lightweight objects, and allowing them 36 | # to be created like this could open the service up to denial-of-service 37 | # attacks. 38 | # 'auto_create_collection' creates a collection when one is needed but does not exist 39 | # 'auto_create_index' creates an index when one is needed but does not exist 40 | #------------------------------------------------------------------------------ 41 | # auto_create_collection = true 42 | # auto_create_index = true 43 | 44 | 45 | ############################################################################### 46 | # RethinkDB Options 47 | # These options are mutually exclusive 48 | # 'connect' will connect to an existing RethinkDB instance 49 | # 'start_rethinkdb' will run an internal RethinkDB instance 50 | #------------------------------------------------------------------------------ 51 | # connect = "localhost:28015" 52 | # start_rethinkdb = false 53 | 54 | 55 | ############################################################################### 56 | # Debug Options 57 | # 'debug' enables debug log statements 58 | #------------------------------------------------------------------------------ 59 | # debug = true 60 | 61 | 62 | ############################################################################### 63 | # Authentication Options 64 | # Each auth subsection will add an endpoint for authenticating through the 65 | # specified provider. 66 | # 'token_secret' is the key used to sign jwts 67 | # 'allow_anonymous' issues new accounts to users without an auth provider 68 | # 'allow_unauthenticated' allows connections that are not tied to a user id 69 | # 'auth_redirect' specifies where users will be redirected to after login 70 | #------------------------------------------------------------------------------ 71 | token_secret = "homvHswmmQYjzZYUAkvFAu/E26CdP9sdoZGpthngJg8bN1C4aYtA2aZW/3I4iLyJGVgvjbrLT8bPRqDWm0D9+w==" 72 | # allow_anonymous = true 73 | # allow_unauthenticated = true 74 | # auth_redirect = "/" 75 | # 76 | # [auth.facebook] 77 | # id = "000000000000000" 78 | # secret = "00000000000000000000000000000000" 79 | # 80 | # [auth.google] 81 | # id = "00000000000-00000000000000000000000000000000.apps.googleusercontent.com" 82 | # secret = "000000000000000000000000" 83 | # 84 | # [auth.twitter] 85 | # id = "0000000000000000000000000" 86 | # secret = "00000000000000000000000000000000000000000000000000" 87 | # 88 | # [auth.github] 89 | # id = "00000000000000000000" 90 | # secret = "0000000000000000000000000000000000000000" 91 | # 92 | # [auth.twitch] 93 | # id = "0000000000000000000000000000000" 94 | # secret = "0000000000000000000000000000000" 95 | -------------------------------------------------------------------------------- /example/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Horizon 6 | 7 | 8 | 58 | 59 | 60 | 61 |
62 |
63 |
64 |
65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /example/src/components/chat.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from '../../../src/index'; 3 | import ChatList from './list'; 4 | import ChatInput from './input'; 5 | 6 | class ChatApp extends Component { 7 | static defaultProps = { 8 | authorId: Date.now() 9 | } 10 | render() { 11 | 12 | const { authorId, messages, sendMessage } = this.props; 13 | 14 | return ( 15 |
16 | 17 | sendMessage({ t: new Date(), text, authorId })} /> 18 |
19 | ); 20 | } 21 | } 22 | 23 | const ChatAppContainer = connect(ChatApp, { 24 | subscriptions: { 25 | messages: (hz) => hz('messages') 26 | .order('t', 'descending') 27 | .limit(8) 28 | }, 29 | mutations: { 30 | sendMessage: (hz) => (message) => hz('messages').store(message) 31 | } 32 | }); 33 | 34 | export default ChatAppContainer; 35 | -------------------------------------------------------------------------------- /example/src/components/input.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class ChatInput extends Component { 4 | constructor(props) { 5 | super(props); 6 | 7 | this.state = { text: '' }; 8 | 9 | this._handleSubmit = this._handleSubmit.bind(this); 10 | } 11 | _handleSubmit(event) { 12 | 13 | const { onSave } = this.props; 14 | const { text } = this.state; 15 | 16 | if (event.keyCode === 13) { 17 | 18 | const value = text.trim(); 19 | 20 | if (value) { 21 | onSave(value); 22 | this.setState({ text: '' }); 23 | } 24 | } 25 | } 26 | render() { 27 | 28 | const { text } = this.state; 29 | 30 | return ( 31 |
32 | this.setState({ text: event.target.value })} 36 | onKeyDown={this._handleSubmit} 37 | autoFocus={true} /> 38 |
39 | ); 40 | } 41 | } 42 | 43 | export default ChatInput; 44 | -------------------------------------------------------------------------------- /example/src/components/list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ChatMessage from './message'; 3 | 4 | const ChatList = ({ messages }) => ( 5 |
6 |
    7 | {messages.map((message) => )} 8 |
9 |
10 | ); 11 | 12 | export default ChatList; 13 | -------------------------------------------------------------------------------- /example/src/components/message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ChatMessage = ({ authorId, text, t }) => ( 4 |
  • 5 | 6 | {text} 7 | {t.toLocaleString()} 8 |
  • 9 | ); 10 | 11 | export default ChatMessage; 12 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Horizon, HorizonProvider, HorizonRoute } from '../../src/index'; 4 | import ChatApp from './components/chat'; 5 | 6 | const horizonInstance = Horizon({ host: 'localhost:8181' }); 7 | 8 | const App = () => ( 9 | 10 | } /> 11 | 12 | ); 13 | 14 | render(, document.getElementById('app1')); 15 | render(, document.getElementById('app2')); 16 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions" : { 3 | "experimentalDecorators": true 4 | } 5 | } -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roman01la/react-horizon/249d493c69ec37647a61d19c48bcf8657fcf909a/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hz", 3 | "version": "0.4.0", 4 | "description": "React Horizon makes it easier to use your React application with horizon.io realtime backend", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "start-example": "NODE_ENV=development watchify ./example/src/index.js -o ./example/dist/js/bundle.js -g envify -t [ babelify --presets [ airbnb stage-1 ] ] -dv", 8 | "build-example": "NODE_ENV=production browserify ./example/src/index.js -o ./example/dist/js/bundle.js -g envify -t [ babelify --presets [ airbnb stage-1 ] ] -dv", 9 | "build": "babel src --out-dir build", 10 | "prepublish": "babel src --out-dir build", 11 | "test": "mocha test/.setup.js test/**/*.spec.js" 12 | }, 13 | "keywords": [ 14 | "horizon", 15 | "react" 16 | ], 17 | "author": "Roman Liutikov (https://github.com/roman01la)", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/roman01la/react-horizon.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/roman01la/react-horizon/issues" 25 | }, 26 | "homepage": "https://github.com/roman01la/react-horizon", 27 | "dependencies": { 28 | "shallowequal": "^0.2.2" 29 | }, 30 | "peerDependencies": { 31 | "@horizon/client": ">=1.1.1", 32 | "react": ">=15.0.2" 33 | }, 34 | "devDependencies": { 35 | "babel-cli": "^6.10.1", 36 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 37 | "babel-preset-airbnb": "^2.0.0", 38 | "babel-preset-stage-1": "^6.5.0", 39 | "babel-register": "^6.9.0", 40 | "chai": "^3.5.0", 41 | "enzyme": "^2.3.0", 42 | "jsdom": "^9.2.1", 43 | "mocha": "^2.5.3", 44 | "react-addons-test-utils": "^15.1.0", 45 | "react-dom": "^15.1.0", 46 | "sinon": "^1.17.4" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roman01la/react-horizon/249d493c69ec37647a61d19c48bcf8657fcf909a/schema.png -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import shallowequal from 'shallowequal'; 3 | 4 | export function withQueries({ subscriptions = {}, mutations = {} }) { 5 | return function(ReactComponent) { 6 | return class extends Component { 7 | static contextTypes = { 8 | hz: PropTypes.func 9 | } 10 | constructor(props, context) { 11 | 12 | super(props, context); 13 | 14 | this._subscriptions = []; 15 | this._mutations = {}; 16 | 17 | this.state = Object.keys(subscriptions) 18 | .reduce((initialState, qname) => { 19 | initialState[qname] = []; 20 | return initialState; 21 | }, {}); 22 | 23 | this._subscribe = this._subscribe.bind(this); 24 | this._unsubscribe = this._unsubscribe.bind(this); 25 | this._createMutations = this._createMutations.bind(this); 26 | } 27 | shouldComponentUpdate(nextProps, nextState) { 28 | return shallowequal(nextProps, this.props) === false || 29 | shallowequal(nextState, this.state) === false; 30 | } 31 | componentWillReceiveProps(nextProps) { 32 | this._unsubscribe(this._subscriptions); 33 | this._subscribe(this.context.hz, nextProps); 34 | } 35 | componentWillMount() { 36 | this._subscribe(this.context.hz, this.props); 37 | this._createMutations(this.context.hz); 38 | } 39 | componentWillUnmount() { 40 | this._unsubscribe(this._subscriptions); 41 | } 42 | _subscribe(hz, props) { 43 | Object.keys(subscriptions) 44 | .forEach((qname) => { 45 | const q = subscriptions[qname]; 46 | const subscription = q(hz, props).watch().subscribe((data) => this.setState({ [qname]: data })); 47 | this._subscriptions.push(subscription); 48 | }); 49 | } 50 | _unsubscribe(subscriptions) { 51 | subscriptions.forEach((q) => q.unsubscribe()); 52 | } 53 | _createMutations(hz) { 54 | Object.keys(mutations) 55 | .forEach((mname) => { 56 | this._mutations[mname] = mutations[mname](hz); 57 | }); 58 | } 59 | render() { 60 | return ; 61 | } 62 | } 63 | } 64 | } 65 | 66 | export default function connect(ReactComponent, options) { 67 | return withQueries(options)(ReactComponent); 68 | } 69 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Horizon } from '@horizon/client/dist/horizon'; 2 | export { default as connect, withQueries } from './connect'; 3 | export { default as HorizonProvider } from './provider'; 4 | export { default as HorizonRoute } from './route'; 5 | -------------------------------------------------------------------------------- /src/provider.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | class HorizonProvider extends Component { 4 | static propTypes = { 5 | instance: PropTypes.func, 6 | } 7 | static childContextTypes = { 8 | hz: PropTypes.func 9 | } 10 | getChildContext() { 11 | return { hz: this.props.instance }; 12 | } 13 | constructor(props, context) { 14 | super(props, context); 15 | 16 | this.props.instance.connect(); 17 | } 18 | render() { 19 | return React.Children.only(this.props.children); 20 | } 21 | } 22 | 23 | export default HorizonProvider; 24 | -------------------------------------------------------------------------------- /src/route.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import shallowequal from 'shallowequal'; 3 | 4 | const noop = () => null; 5 | 6 | class HorizonRoute extends Component { 7 | static contextTypes = { 8 | hz: PropTypes.func 9 | } 10 | static propTypes = { 11 | renderConnecting: PropTypes.func, 12 | renderDisconnected: PropTypes.func, 13 | renderConnected: PropTypes.func, 14 | renderSuccess: PropTypes.func, 15 | renderFailure: PropTypes.func, 16 | } 17 | static defaultProps = { 18 | renderConnecting: noop, 19 | renderDisconnected: noop, 20 | renderConnected: noop, 21 | renderSuccess: noop, 22 | renderFailure: noop, 23 | } 24 | constructor(props, context) { 25 | super(props, context); 26 | 27 | this.state = { 28 | status: undefined, 29 | error: undefined, 30 | }; 31 | } 32 | componentWillMount() { 33 | this._status = this.context.hz.status( 34 | ({ type }) => this.setState({ status: type }), 35 | (error) => this.setState({ error })); 36 | } 37 | componentWillUnmount() { 38 | this._status.unsubscribe(); 39 | } 40 | shouldComponentUpdate(nextProps, nextState) { 41 | return shallowequal(nextProps, this.props) === false || 42 | shallowequal(nextState, this.state) === false; 43 | } 44 | render() { 45 | 46 | const { status, error } = this.state; 47 | 48 | switch (this.state.status) { 49 | case 'unconnected': 50 | return this.props.renderConnecting(); 51 | case 'connected': 52 | return this.props.renderConnected(); 53 | case 'ready': 54 | return this.props.renderSuccess(); 55 | case 'error': 56 | return this.props.renderFailure(error) 57 | case 'disconnected': 58 | return this.props.renderDisconnected(); 59 | default: 60 | return null; 61 | } 62 | } 63 | } 64 | 65 | export default HorizonRoute; 66 | -------------------------------------------------------------------------------- /test/.setup.js: -------------------------------------------------------------------------------- 1 | require('babel-register')(); 2 | 3 | var jsdom = require('jsdom').jsdom; 4 | 5 | var exposedProperties = ['window', 'navigator', 'document']; 6 | 7 | global.document = jsdom(''); 8 | global.window = document.defaultView; 9 | Object.keys(document.defaultView).forEach((property) => { 10 | if (typeof global[property] === 'undefined') { 11 | exposedProperties.push(property); 12 | global[property] = document.defaultView[property]; 13 | } 14 | }); 15 | 16 | global.navigator = { 17 | userAgent: 'node.js' 18 | }; 19 | 20 | documentRef = document; 21 | -------------------------------------------------------------------------------- /test/connect.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import sinon from 'sinon'; 3 | import { mount } from 'enzyme'; 4 | import { expect } from 'chai'; 5 | import { connect, HorizonProvider } from '../src/index'; 6 | import { Horizon, TestComp } from './utils'; 7 | 8 | describe('connect', () => { 9 | 10 | it('should create container component with 2 subscriptions', () => { 11 | 12 | const TestCompContainer = connect(TestComp, { 13 | subscriptions: { 14 | items: (hz) => hz('items'), 15 | users: (hz) => hz('users'), 16 | } 17 | }); 18 | 19 | const wrapper = mount(( 20 | 21 | 22 | 23 | )); 24 | 25 | expect(wrapper.find(TestCompContainer).nodes[0]._subscriptions) 26 | .to.have.length(2); 27 | }); 28 | 29 | it('should create container component with 2 mutations', () => { 30 | 31 | const TestCompContainer = connect(TestComp, { 32 | mutations: { 33 | createItem: (hz) => (item) => hz('items').store(item), 34 | addUser: (hz) => (user) => hz('users').store(user), 35 | } 36 | }); 37 | 38 | const wrapper = mount(( 39 | 40 | 41 | 42 | )); 43 | 44 | expect(wrapper.find(TestCompContainer).nodes[0]._mutations) 45 | .to.have.keys(['createItem', 'addUser']); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/provider.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import sinon from 'sinon'; 3 | import { mount } from 'enzyme'; 4 | import { expect } from 'chai'; 5 | import { HorizonProvider } from '../src/index'; 6 | import { Horizon } from './utils'; 7 | 8 | describe('HorizonProvider', () => { 9 | 10 | it('should have `instance` prop with a value of an instance of `Horizon`', () => { 11 | 12 | const wrapper = mount(( 13 | 14 |
    15 | 16 | )); 17 | 18 | expect(wrapper.prop('instance')) 19 | .to.have.keys(['watch', 'connect', 'status']); 20 | }); 21 | 22 | it('should call `connect` method of the `instance` prop', () => { 23 | 24 | const connect = sinon.spy(); 25 | 26 | const wrapper = mount(( 27 | 28 |
    29 | 30 | )); 31 | 32 | expect(connect.calledOnce).to.equal(true); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/route.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import sinon from 'sinon'; 3 | import { mount } from 'enzyme'; 4 | import { expect } from 'chai'; 5 | import { HorizonProvider, HorizonRoute } from '../src/index'; 6 | import { Horizon, assertStatus, assertStatusPropCall } from './utils'; 7 | 8 | describe('HorizonRoute', () => { 9 | 10 | it('should call `unsubscribe` method on object returned by calling `status` when unmounting', () => { 11 | 12 | const unsubscribe = sinon.spy(); 13 | const status = () => ({ unsubscribe }); 14 | 15 | const wrapper = mount(( 16 | 17 | 18 | 19 | )); 20 | 21 | wrapper.unmount(); 22 | 23 | expect(unsubscribe.calledOnce).to.equal(true); 24 | }); 25 | 26 | it('should set `error` to `true`', () => { 27 | 28 | const status = (onNext, onError) => onError(true); 29 | 30 | const wrapper = mount(( 31 | 32 | 33 | 34 | )); 35 | 36 | expect(wrapper.find(HorizonRoute).nodes[0].state.error).to.equal(true); 37 | }); 38 | 39 | it('should set status `unconnected`', () => assertStatus('unconnected')); 40 | it('should set status `connected`', () => assertStatus('connected')); 41 | it('should set status `ready`', () => assertStatus('ready')); 42 | it('should set status `error`', () => assertStatus('error')); 43 | it('should set status `disconnected`', () => assertStatus('disconnected')); 44 | 45 | it('should call prop `renderConnecting`', () => assertStatusPropCall('unconnected', 'renderConnecting')); 46 | it('should call prop `renderConnected`', () => assertStatusPropCall('connected', 'renderConnected')); 47 | it('should call prop `renderSuccess`', () => assertStatusPropCall('ready', 'renderSuccess')); 48 | it('should call prop `renderFailure`', () => assertStatusPropCall('error', 'renderFailure')); 49 | it('should call prop `renderDisconnected`', () => assertStatusPropCall('disconnected', 'renderDisconnected')); 50 | }); 51 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | import { HorizonProvider, HorizonRoute } from '../src/index'; 5 | 6 | export const TestComp = () =>
    ; 7 | 8 | export function Horizon({ connect, status } = {}) { 9 | const hz = Horizon; 10 | hz.watch = () => ({ subscribe: (handler) => handler(['subscription']) }); 11 | hz.connect = connect || (() => undefined); 12 | hz.status = status || (() => undefined); 13 | return hz; 14 | } 15 | 16 | export function assertStatus(type) { 17 | 18 | const status = (onNext, onError) => onNext({ type }); 19 | 20 | const wrapper = mount(( 21 | 22 | 23 | 24 | )); 25 | 26 | expect(wrapper.find(HorizonRoute).nodes[0].state.status).to.equal(type); 27 | } 28 | 29 | export function assertStatusPropCall(type, propName) { 30 | 31 | let called = false; 32 | const status = (onNext, onError) => onNext({ type }); 33 | const handler = () => (called = true, null); 34 | const props = { [propName]: handler }; 35 | 36 | const wrapper = mount(( 37 | 38 | 39 | 40 | )); 41 | 42 | expect(called).to.equal(true); 43 | } 44 | -------------------------------------------------------------------------------- /test/withQueries.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import sinon from 'sinon'; 3 | import { mount } from 'enzyme'; 4 | import { expect } from 'chai'; 5 | import { withQueries, HorizonProvider } from '../src/index'; 6 | import { Horizon } from './utils'; 7 | 8 | describe('withQueries', () => { 9 | 10 | it('should create container component with 2 subscriptions', () => { 11 | @withQueries({ 12 | subscriptions: { 13 | items: (hz) => hz('items'), 14 | users: (hz) => hz('users'), 15 | } 16 | }) 17 | class WithSubscriptions extends React.Component { 18 | render() { 19 | return
    20 | } 21 | } 22 | 23 | const wrapper = mount(( 24 | 25 | 26 | 27 | )); 28 | 29 | expect(wrapper.find(WithSubscriptions).nodes[0]._subscriptions) 30 | .to.have.length(2); 31 | }); 32 | 33 | it('should create container component with 2 mutations', () => { 34 | @withQueries({ 35 | mutations: { 36 | createItem: (hz) => (item) => hz('items').store(item), 37 | addUser: (hz) => (user) => hz('users').store(user), 38 | } 39 | }) 40 | class WithMutations extends React.Component { 41 | render() { 42 | return
    43 | } 44 | } 45 | 46 | const wrapper = mount(( 47 | 48 | 49 | 50 | )); 51 | 52 | expect(wrapper.find(WithMutations).nodes[0]._mutations) 53 | .to.have.keys(['createItem', 'addUser']); 54 | }); 55 | }); 56 | --------------------------------------------------------------------------------