Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mcs-lite-connect): add isWebSocketClose feature [BREAKING] #234

Merged
merged 6 commits into from
Mar 16, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
|-------------|-----------------|-------------|---------------|
| `babel-preset-mcs-lite` | Babel preset used by MCS Lite | [![npm][babel-preset-mcs-lite-npm-badge]][babel-preset-mcs-lite-npm] | [![npm downloads][babel-preset-mcs-lite-npm-downloads]][babel-preset-mcs-lite-npm]
| `eslint-config-mcs-lite` | Eslint config used by MCS Lite | [![npm][eslint-config-mcs-lite-npm-badge]][eslint-config-mcs-lite-npm] | [![npm downloads][eslint-config-mcs-lite-npm-downloads]][eslint-config-mcs-lite-npm]
| `mcs-lite-connect` | Connect MCS with WebSocket | [![npm][mcs-lite-connect-npm-badge]][mcs-lite-connect-npm] | [![npm downloads][mcs-lite-connect-npm-downloads]][mcs-lite-connect-npm]
| [`mcs-lite-connect`](./packages/mcs-lite-connect) | Connect MCS with WebSocket | [![npm][mcs-lite-connect-npm-badge]][mcs-lite-connect-npm] | [![npm downloads][mcs-lite-connect-npm-downloads]][mcs-lite-connect-npm]
| `mcs-lite-demo-nextjs` | Demo how to use mcs-lite-ui. | | |
| `mcs-lite-design` | The source images to be compressed used by MCS Lite | [![npm][mcs-lite-design-npm-badge]][mcs-lite-design-npm] | [![npm downloads][mcs-lite-design-npm-downloads]][mcs-lite-design-npm]
| `mcs-lite-icon` | Convert SVG icon to React components | [![npm][mcs-lite-icon-npm-badge]][mcs-lite-icon-npm] | [![npm downloads][mcs-lite-icon-npm-downloads]][mcs-lite-icon-npm]
Expand Down
59 changes: 59 additions & 0 deletions packages/mcs-lite-connect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# mcs-lite-connect
> Connect MCS with WebSocket.

## Installation

```console
$ npm i mcs-lite-connect --save
```

## Usage

`connectSocket` is a higher-order components based on [recompose](https://github.com/acdlite/recompose) and [W3CWebSocket](https://github.com/theturtle32/WebSocket-Node). It will handle websocket lifecycle for your React component.

```js
import { connectSocket } from 'mcs-lite-connect';

const Component = connectSocket(
// 1. urlMapper => (ownerProps: Object) => string
props =>
'ws://localhost:8000/deviceId/12345/deviceKey/' + props.key,

// 2. onMessage => (ownerProps: Object) => datapoint => void
props =>
datapoint => props.setDatapoint(props.deviceId, datapoint),

// 3. propsMapper => state => props
({ readyState, send, createWebSocket }) => ({
send,
isWebSocketClose: readyState.sender === 3,
reconnect: createWebSocket,
}),
)(BaseComponent),

```

## API

### `urlMapper => (ownerProps: Object) => string`

Set the **URL** to be connected. There are two connections:

- *Sender* : The **Send-Only** connection via `${URL}`.
- *Viewer* : The **Read-Only** connection via `${URL}/viewer`.

### `onMessage => (ownerProps: Object) => datapoint => void`

The **callback function** of *Viewer*. It will be invoked when receiving messages (*datapoint*) from the server.

### `propsMapper => state => props`

A function that maps **internal state** to a new collection of props that are passed to the base component. There are three states:

- `send(payload: String)` : Immediately sends the specified payload (*datapoint*) to server.
- `readyState`: [Ready state constants](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket#Ready_state_constants).
- `createWebSocket`: A convenience function for reconnecting.

## Inspired by

- [react-websocket-view](https://github.com/jollen/react-websocket-view)
81 changes: 57 additions & 24 deletions packages/mcs-lite-connect/src/connectSocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,96 @@ import createEagerFactory from 'recompose/createEagerFactory';
import createHelper from 'recompose/createHelper';
import { w3cwebsocket as W3CWebSocket } from 'websocket';

// ref: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket#Ready_state_constants
const CLOSING = 2;

const emptyFunction = () => {
console.warn('[mcs-lite-connect] W3CWebSocket is not ready.');
};

const setReadyState = (name, websocket) => prevState => ({
readyState: {
...prevState.readyState,
[name]: websocket.readyState,
},
});

/**
* connectSocket
* urlMapper => props => string
* onMessage => props => e => void
* sendPropsName: string
* urlMapper => (ownerProps: Object) => string
* onMessage => (ownerProps: Object) => datapoint => void
* propsMapper => state => props
*
* @author Michael Hsu
*/

const connectSocket = (urlMapper, onMessage, sendPropsName) => (BaseComponent) => {
const connectSocket = (urlMapper, onMessage, propsMapper) => (BaseComponent) => {
const factory = createEagerFactory(BaseComponent);
const initialState = {
send: emptyFunction, // Remind: Pass this Sender function as props.
readyState: { viewer: '', sender: '' }, // 0 ~ 3
};

return class ConnectMCS extends React.Component {
state = { exposeSenderFunction: emptyFunction };
state = initialState;
isComponentUnmount = false; // Hint: To avoid calling setState of unmounted component.
componentWillMount = () => this.createWebSocket();
componentWillReceiveProps = () => this.createWebSocket();
componentWillUnmount = () => this.close();
componentWillUnmount = () => {
this.viewer.close();
this.sender.close();
this.setState(initialState);
this.isComponentUnmount = true;
};

/**
* This function will be passed to component as props, so that the consumer
* could have the controllability of reconnecting.
*
* @author Michael Hsu
*/
createWebSocket = () => {
const URL = urlMapper(this.props);
if (!URL) return; // Hint: deviceId Not Ready.
if (!URL) return; // Hint: deviceKey Not Ready.

if (!this.viewer) {
if (!this.viewer || this.viewer.readyState >= CLOSING) {
this.viewer = new W3CWebSocket(`${URL}/viewer`);
// this.viewer.onopen = data => console.info('viewer onopen', data);
// this.viewer.onerror = error => console.info('viewer onerror', error);
// this.viewer.onclose = data => console.info('viewer onclose', data);
this.viewer.onopen = () =>
this.setState(setReadyState('viewer', this.viewer));
this.viewer.onclose = () => {
if (this.isComponentUnmount) return;
this.setState(setReadyState('viewer', this.viewer));
};
this.viewer.onmessage = (payload) => {
const data = JSON.parse(payload.data);
onMessage(this.props)(data); // Remind: Handle receieve messages.
};
this.viewer.onerror = error => console.info('viewer onerror', error);
}

if (!this.sender) {
if (!this.sender || this.sender.readyState >= CLOSING) {
this.sender = new W3CWebSocket(`${URL}`);
this.sender.onopen = () =>
this.setState({ exposeSenderFunction: this.sender.send.bind(this.sender) });
// this.sender.onerror = error => console.info('sender onerror', error);
// this.sender.onclose = data => console.info('sender onclose', data);
// this.sender.onmessage = e => console.info('sender onmessage', e.data);
this.sender.onopen = () => {
this.setState({ send: this.sender.send.bind(this.sender) });
this.setState(setReadyState('sender', this.sender));
};
this.viewer.onclose = () => {
if (this.isComponentUnmount) return;
this.setState(setReadyState('sender', this.sender));
};
this.sender.onmessage = e => console.info('sender onmessage', e.data);
this.sender.onerror = error => console.info('sender onerror', error);
}
}

close = () => {
this.viewer.close();
this.sender.close();
this.setState({ exposeSenderFunction: emptyFunction });
}

render() {
return factory({
...this.props,
[sendPropsName]: this.state.exposeSenderFunction,
...propsMapper({
createWebSocket: this.createWebSocket,
send: this.state.send,
readyState: this.state.readyState,
}),
});
}
};
Expand Down
16 changes: 15 additions & 1 deletion packages/mcs-lite-mobile-web/mcs-lite-mobile-web.pot
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2017-03-13T06:16:13.827Z\n"
"POT-Creation-Date: 2017-03-16T10:02:16.879Z\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"MIME-Version: 1.0\n"
Expand Down Expand Up @@ -265,6 +265,13 @@ msgstr ""
msgid "登出"
msgstr ""

#: ./messages.json
#. [WebSocketNotification.info] - Error message
#. defaultMessage is:
#. 目前網路連線斷線,請問要重新連線嗎?
msgid "目前網路連線斷線,請問要重新連線嗎?"
msgstr ""

#: ./messages.json
#. [DeviceList.noDevice]
#. defaultMessage is:
Expand Down Expand Up @@ -332,6 +339,13 @@ msgstr ""
msgid "輸入新密碼"
msgstr ""

#: ./messages.json
#. [WebSocketNotification.reconnect] - For button content
#. defaultMessage is:
#. 重新連線
msgid "重新連線"
msgstr ""

#: ./messages.json
#. [DeviceDataChannelDetail.defaultQueryLatest] - label hint
#. defaultMessage is:
Expand Down
12 changes: 12 additions & 0 deletions packages/mcs-lite-mobile-web/messages.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
[
{
"id": "WebSocketNotification.info",
"description": "Error message",
"defaultMessage": "目前網路連線斷線,請問要重新連線嗎?",
"filepath": "./src/components/WebSocketNotification/messages.js"
},
{
"id": "WebSocketNotification.reconnect",
"description": "For button content",
"defaultMessage": "重新連線",
"filepath": "./src/components/WebSocketNotification/messages.js"
},
{
"id": "Account.account",
"description": "Title",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { PropTypes } from 'react';
import styled from 'styled-components';
import { MobileContentWrapper, Notification, Button, P } from 'mcs-lite-ui';
import Transition from 'react-motion-ui-pack';

const Fixed = styled.div`
position: fixed;
left: 0;
right: 0;
top: ${props => props.theme.mobile.headerHeight};
margin-top: 16px;
`;

const Wrapper = styled(MobileContentWrapper)`
padding: 0 16px;
`;

const WebSocketNotification = ({ onClick, getMessages: t }) =>
<Fixed>
<Wrapper>
<Transition
component={false}
appear={{ opacity: 0.5, translateY: -40 }}
enter={{ opacity: 1, translateY: 0 }}
>
<Notification key="notification">
<P>{t('info')}</P>
<Button onClick={onClick}>{t('reconnect')}</Button>
</Notification>
</Transition>
</Wrapper>
</Fixed>;

WebSocketNotification.displayName = 'WebSocketNotification';
WebSocketNotification.propTypes = {
// Props
onClick: PropTypes.func.isRequired,

// React-intl I18n
getMessages: PropTypes.func.isRequired,
};

export default WebSocketNotification;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import toJson from 'enzyme-to-json';
import R from 'ramda';
import { ThemeProvider } from 'styled-components';
import mobileTheme from '../../../utils/mobileTheme';
import WebSocketNotification from '../WebSocketNotification';

it('should renders <WebSocketNotification> correctly', () => {
const wrapper = shallow(
<WebSocketNotification
getMessages={R.identity}
onClick={() => {}}
/>,
);

expect(toJson(wrapper)).toMatchSnapshot();
});


it('should handle onClick', () => {
const mockOnClick = jest.fn();
const wrapper = mount(
<ThemeProvider theme={mobileTheme}>
<WebSocketNotification
getMessages={R.identity}
onClick={mockOnClick}
/>
</ThemeProvider>,
);

// Before eventHandler with submit type
expect(mockOnClick).not.toHaveBeenCalled();

// After eventHandler with submit type
wrapper.find('button').simulate('click');
expect(mockOnClick).toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
exports[`test should renders <WebSocketNotification> correctly 1`] = `
<styled.div>
<styled.div>
<Transition
appear={
Object {
"opacity": 0.5,
"translateY": -40,
}
}
component={false}
enter={
Object {
"opacity": 1,
"translateY": 0,
}
}
leave={
Object {
"opacity": 0,
}
}
onEnter={[Function]}
onLeave={[Function]}
runOnMount={true}>
<Notification>
<P>
info
</P>
<Button
block={false}
component="button"
kind="primary"
onClick={[Function]}
round={false}
size="normal"
square={false}>
reconnect
</Button>
</Notification>
</Transition>
</styled.div>
</styled.div>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
exports[`test should render Container correctly with HOC 1`] = `<InjectIntl(withPropsOnChange(WebSocketNotification)) />`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
exports[`test should return messages 1`] = `
Object {
"WebSocketNotification.info": Object {
"defaultMessage": "目前網路連線斷線,請問要重新連線嗎?",
"description": "Error message",
"id": "WebSocketNotification.info",
},
"WebSocketNotification.reconnect": Object {
"defaultMessage": "重新連線",
"description": "For button content",
"id": "WebSocketNotification.reconnect",
},
}
`;
Loading