diff --git a/app/components/channel_list/channel_list.js b/app/components/channel_list/channel_list.js
index 5a4c31310..4202e2afc 100644
--- a/app/components/channel_list/channel_list.js
+++ b/app/components/channel_list/channel_list.js
@@ -15,22 +15,27 @@ import deepEqual from 'deep-equal';
const Styles = StyleSheet.create({
container: {
- flex: 1,
- ...Platform.select({
- ios: {
- marginTop: 20
- }
- })
+ flex: 1
},
scrollContainer: {
flex: 1
},
headerContainer: {
- justifyContent: 'center',
flexDirection: 'column',
- height: 50,
+ ...Platform.select({
+ ios: {
+ height: 64,
+ justifyContent: 'flex-end'
+ },
+ android: {
+ height: 56,
+ justifyContent: 'center',
+ paddingTop: 10
+ }
+ }),
width: 300,
- paddingLeft: 10
+ paddingLeft: 10,
+ paddingBottom: 12
},
header: {
fontSize: 18,
diff --git a/app/initial_state.js b/app/initial_state.js
index baac3e574..fdf740d41 100644
--- a/app/initial_state.js
+++ b/app/initial_state.js
@@ -3,6 +3,8 @@
import Config from 'assets/config.json';
+import Routes from 'app/navigation/routes';
+
const state = {
entities: {
general: {
@@ -254,9 +256,7 @@ const state = {
navigation: {
index: 0,
routes: [
- {
- key: 'Root'
- }
+ Routes.Root
],
leftDrawerOpen: false,
leftDrawerRoute: null,
diff --git a/app/navigation/router.js b/app/navigation/router.js
index 9bab52154..e30010023 100644
--- a/app/navigation/router.js
+++ b/app/navigation/router.js
@@ -15,7 +15,6 @@ import Drawer from 'app/components/drawer';
import FormattedText from 'app/components/formatted_text';
import OptionsModal from 'app/components/options_modal';
import {RouteTransitions} from 'app/navigation/routes';
-import {getComponentForScene} from 'app/scenes';
class Router extends React.Component {
static propTypes = {
@@ -27,14 +26,53 @@ class Router extends React.Component {
}).isRequired
};
+ headerEventSubscriptions = {};
+
+ emitHeaderEvent = (event) => {
+ const subscription = this.headerEventSubscriptions[event];
+ if (subscription) {
+ subscription();
+ }
+ }
+
+ subscribeToHeaderEvent = (event, callback) => {
+ this.headerEventSubscriptions[event] = callback;
+ }
+
+ unsubscribeFromHeaderEvent = (event) => {
+ if (this.headerEventSubscriptions[event]) {
+ Reflect.deleteProperty(this.headerEventSubscriptions, event);
+ }
+ }
+
+ wrapHeaderComponent = (fx) => (props) => {
+ if (fx && props.scene.isActive) {
+ return fx(props, this.emitHeaderEvent);
+ }
+
+ return null;
+ }
+
+ extractNavigationProps = (route) => {
+ return Object.assign({}, route.navigationProps, route.component.navigationProps);
+ }
+
renderTransition = (transitionProps) => {
- let title;
- if (transitionProps.scene.route.title) {
- title = (
+ const navigationProps = this.extractNavigationProps(transitionProps.scene.route);
+ let navBar = null;
+ if (!navigationProps.hideNavBar) {
+ const renderLeftComponent = transitionProps.navigationState.index > 0 ? this.wrapHeaderComponent(navigationProps.renderBackButton) : this.wrapHeaderComponent(navigationProps.renderLeftComponent);
+ const renderTitleComponent = navigationProps.renderTitleComponent ? this.wrapHeaderComponent(navigationProps.renderTitleComponent) : this.renderTitle;
+ const renderRightComponent = this.wrapHeaderComponent(navigationProps.renderRightComponent);
+
+ navBar = (
);
}
@@ -57,6 +95,7 @@ class Router extends React.Component {
return (
{renderedScenes}
- {title}
+ {navBar}
);
};
renderTitle = ({scene}) => {
- const title = scene.route.title;
+ const navigationProps = this.extractNavigationProps(scene.route);
+ const title = navigationProps.title;
if (!title) {
return null;
@@ -96,9 +136,15 @@ class Router extends React.Component {
};
renderRoute = (route) => {
- const SceneComponent = getComponentForScene(route.key);
+ const SceneComponent = route.component;
- return ;
+ return (
+
+ );
};
configureTransition = () => {
@@ -110,11 +156,14 @@ class Router extends React.Component {
render = () => {
const {
+ index,
leftDrawerOpen,
leftDrawerRoute,
rightDrawerOpen,
- rightDrawerRoute
+ rightDrawerRoute,
+ routes
} = this.props.navigation;
+ const navigationProps = this.extractNavigationProps(routes[index]);
let leftDrawerContent;
if (leftDrawerRoute) {
@@ -142,7 +191,7 @@ class Router extends React.Component {
panOpenMask={0.1}
panCloseMask={0.2}
panThreshold={0.2}
- acceptPan={true}
+ acceptPan={navigationProps.allowSwipe}
negotiatePan={true}
>
{
+ return ;
+ },
+ renderTitleComponent: (props, emitter) => {
+ return ;
+ },
+ renderRightComponent: (props, emitter) => {
+ return ;
+ }
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ leftSidebarOpen: false,
+ rightSidebarOpen: false
+ };
+ }
+
componentWillMount() {
+ this.props.subscribeToHeaderEvent('open_channel_drawer', this.openChannelDrawer);
+ this.props.subscribeToHeaderEvent('open_right_menu', this.openRightMenuDrawer);
+ this.props.subscribeToHeaderEvent('show_channel_info', this.props.actions.goToChannelInfo);
const teamId = this.props.currentTeam.id;
this.props.actions.initWebSocket();
this.loadChannels(teamId);
@@ -47,6 +76,7 @@ export default class Channel extends React.PureComponent {
componentWillUnmount() {
this.props.actions.closeWebSocket();
+ this.props.unsubscribeFromHeaderEvent('open_channel_drawer');
}
loadChannels = (teamId) => {
@@ -57,15 +87,19 @@ export default class Channel extends React.PureComponent {
};
openChannelDrawer = () => {
- this.refs.postTextbox.getWrappedInstance().blur();
+ this.postTextbox.getWrappedInstance().blur();
this.props.actions.openChannelDrawer();
};
openRightMenuDrawer = () => {
- this.refs.postTextbox.getWrappedInstance().blur();
+ this.postTextbox.getWrappedInstance().blur();
this.props.actions.openRightMenuDrawer();
};
+ attachPostTextbox = (c) => {
+ this.postTextbox = c;
+ }
+
render() {
const {
currentTeam,
@@ -84,16 +118,10 @@ export default class Channel extends React.PureComponent {
behavior='padding'
style={{flex: 1, backgroundColor: theme.centerChannelBg}}
>
-
-
+
+ props.emitter('open_channel_drawer')}
+ style={{height: 25, width: 25, marginLeft: 10, marginRight: 10}}
+ >
+
+
+
+ );
+}
+
+ChannelDrawerButton.propTypes = {
+ emitter: PropTypes.func.isRequired,
+ theme: PropTypes.object
+};
+
+ChannelDrawerButton.defaultProps = {
+ currentChannel: {},
+ theme: {}
+};
+
+function mapStateToProps(state) {
+ return {
+ theme: getTheme(state)
+ };
+}
+
+export default connect(mapStateToProps)(ChannelDrawerButton);
diff --git a/app/scenes/channel/channel_menu_button.js b/app/scenes/channel/channel_menu_button.js
new file mode 100644
index 000000000..e93714bb9
--- /dev/null
+++ b/app/scenes/channel/channel_menu_button.js
@@ -0,0 +1,47 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {PropTypes} from 'react';
+import {connect} from 'react-redux';
+import {
+ TouchableOpacity,
+ View
+} from 'react-native';
+import Icon from 'react-native-vector-icons/FontAwesome';
+
+import {getTheme} from 'service/selectors/entities/preferences';
+
+function ChannelMenuButton(props) {
+ return (
+
+ props.emitter('open_right_menu')}
+ style={{height: 25, width: 25, marginLeft: 10, marginRight: 10}}
+ >
+
+
+
+ );
+}
+
+ChannelMenuButton.propTypes = {
+ emitter: PropTypes.func.isRequired,
+ theme: PropTypes.object
+};
+
+ChannelMenuButton.defaultProps = {
+ currentChannel: {},
+ theme: {}
+};
+
+function mapStateToProps(state) {
+ return {
+ theme: getTheme(state)
+ };
+}
+
+export default connect(mapStateToProps)(ChannelMenuButton);
diff --git a/app/scenes/channel/channel_title.js b/app/scenes/channel/channel_title.js
new file mode 100644
index 000000000..63de2ab8c
--- /dev/null
+++ b/app/scenes/channel/channel_title.js
@@ -0,0 +1,49 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {PropTypes} from 'react';
+import {connect} from 'react-redux';
+import {
+ Text,
+ TouchableOpacity,
+ View
+} from 'react-native';
+
+import {getCurrentChannel} from 'service/selectors/entities/channels';
+import {getTheme} from 'service/selectors/entities/preferences';
+
+function ChannelTitle(props) {
+ const channelName = props.currentChannel.display_name;
+ return (
+ props.emitter('show_channel_info')}
+ >
+
+
+ {channelName}
+
+
+
+ );
+}
+
+ChannelTitle.propTypes = {
+ currentChannel: PropTypes.object,
+ emitter: PropTypes.func.isRequired,
+ theme: PropTypes.object
+};
+
+ChannelTitle.defaultProps = {
+ currentChannel: {},
+ theme: {}
+};
+
+function mapStateToProps(state) {
+ return {
+ currentChannel: getCurrentChannel(state),
+ theme: getTheme(state)
+ };
+}
+
+export default connect(mapStateToProps)(ChannelTitle);
diff --git a/app/scenes/channel_info/channel_info_container.js b/app/scenes/channel_info/channel_info_container.js
index 504ec01ee..a695e8a0c 100644
--- a/app/scenes/channel_info/channel_info_container.js
+++ b/app/scenes/channel_info/channel_info_container.js
@@ -2,7 +2,8 @@
// See License.txt for license information.
import {bindActionCreators} from 'redux';
-import {connect} from 'react-redux';
+
+import navigationSceneConnect from '../navigationSceneConnect';
import {goToChannelMembers, goBack} from 'app/actions/navigation';
import {getChannelStats} from 'service/actions/channels';
@@ -46,4 +47,4 @@ function mapDispatchToProps(dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(ChannelInfo);
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(ChannelInfo);
diff --git a/app/scenes/channel_members/channel_members_container.js b/app/scenes/channel_members/channel_members_container.js
index 5eb8eb429..07245cf23 100644
--- a/app/scenes/channel_members/channel_members_container.js
+++ b/app/scenes/channel_members/channel_members_container.js
@@ -2,7 +2,8 @@
// See License.txt for license information.
import {bindActionCreators} from 'redux';
-import {connect} from 'react-redux';
+
+import navigationSceneConnect from '../navigationSceneConnect';
import {getCurrentChannel, getCurrentChannelStats} from 'service/selectors/entities/channels';
import {getMyPreferences} from 'service/selectors/entities/preferences';
@@ -33,4 +34,4 @@ function mapDispatchToProps(dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(ChannelMembers);
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(ChannelMembers);
diff --git a/app/scenes/index.js b/app/scenes/index.js
index 3c276af75..7c195edf5 100644
--- a/app/scenes/index.js
+++ b/app/scenes/index.js
@@ -14,7 +14,7 @@ import Search from './search/search_container.js';
import SelectServer from './select_server/select_server_container.js';
import SelectTeam from './select_team/select_team_container.js';
-const scenes = {
+module.exports = {
ChannelView: Channel, // Special case the name for this one to avoid ambiguity
ChannelDrawer,
ChannelInfo,
@@ -28,7 +28,3 @@ const scenes = {
SelectServer,
SelectTeam
};
-
-export function getComponentForScene(key) {
- return scenes[key];
-}
diff --git a/app/scenes/login/login_container.js b/app/scenes/login/login_container.js
index bc95c8747..a085afd76 100644
--- a/app/scenes/login/login_container.js
+++ b/app/scenes/login/login_container.js
@@ -2,7 +2,8 @@
// See License.txt for license information.
import {bindActionCreators} from 'redux';
-import {connect} from 'react-redux';
+
+import navigationSceneConnect from '../navigationSceneConnect';
import {getClientConfig, getLicenseConfig} from 'service/actions/general';
import LoginActions from 'app/actions/views/login';
@@ -40,4 +41,4 @@ function mapDispatchToProps(dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(Login);
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(Login);
diff --git a/app/scenes/mfa/mfa_container.js b/app/scenes/mfa/mfa_container.js
index 2f7217e95..220a011ab 100644
--- a/app/scenes/mfa/mfa_container.js
+++ b/app/scenes/mfa/mfa_container.js
@@ -2,7 +2,8 @@
// See License.txt for license information.
import {bindActionCreators} from 'redux';
-import {connect} from 'react-redux';
+
+import navigationSceneConnect from '../navigationSceneConnect';
import {goBack} from 'app/actions/navigation';
import {login} from 'service/actions/users';
@@ -28,4 +29,4 @@ function mapDispatchToProps(dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(Mfa);
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(Mfa);
diff --git a/app/scenes/navigationSceneConnect.js b/app/scenes/navigationSceneConnect.js
new file mode 100644
index 000000000..fba77cde0
--- /dev/null
+++ b/app/scenes/navigationSceneConnect.js
@@ -0,0 +1,66 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+/* eslint-disable */
+
+import {connect} from 'react-redux';
+import React, {PropTypes} from 'react';
+import {
+ Platform,
+ TouchableOpacity,
+ View
+} from 'react-native';
+import Icon from 'react-native-vector-icons/FontAwesome';
+
+import FormattedText from 'app/components/formatted_text';
+
+const defaults = {
+ renderBackButton: (props) => {
+ return (
+
+
+
+
+ );
+ },
+ renderTitleComponent: (props) => {
+ const navProps = props.scene.route.navigationProps || {};
+ const title = navProps.title;
+ if (title) {
+ return (
+
+
+
+ );
+ }
+ },
+ headerStyle: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#367FB0'
+ },
+ allowSwipe: false
+};
+
+export default (stateProps, dispatchProps) => (WrappedComponent) => {
+ const componentNavigationProps = WrappedComponent.WrappedComponent ? WrappedComponent.WrappedComponent.navigationProps : WrappedComponent.navigationProps;
+
+ WrappedComponent.navigationProps = Object.assign({}, defaults, componentNavigationProps);
+
+ return connect(stateProps, dispatchProps)(WrappedComponent);
+};
diff --git a/app/scenes/right_menu_drawer/right_menu_drawer.js b/app/scenes/right_menu_drawer/right_menu_drawer.js
index 448101eca..c9e6818b9 100644
--- a/app/scenes/right_menu_drawer/right_menu_drawer.js
+++ b/app/scenes/right_menu_drawer/right_menu_drawer.js
@@ -21,7 +21,10 @@ const Styles = StyleSheet.create({
flex: 1,
...Platform.select({
ios: {
- marginTop: 20
+ paddingTop: 20
+ },
+ android: {
+ paddingTop: 5
}
})
},
diff --git a/app/scenes/root/root.js b/app/scenes/root/root.js
index c1b8a6232..5d08e561f 100644
--- a/app/scenes/root/root.js
+++ b/app/scenes/root/root.js
@@ -21,6 +21,10 @@ export default class Root extends React.Component {
}).isRequired
};
+ static navigationProps = {
+ hideNavBar: true
+ }
+
componentDidMount() {
// Any initialization logic for navigation, setting up the client, etc should go here
this.init();
diff --git a/app/scenes/root/root_container.js b/app/scenes/root/root_container.js
index bcbf4bb1a..166f80a05 100644
--- a/app/scenes/root/root_container.js
+++ b/app/scenes/root/root_container.js
@@ -2,7 +2,8 @@
// See License.txt for license information.
import {bindActionCreators} from 'redux';
-import {connect} from 'react-redux';
+
+import navigationSceneConnect from '../navigationSceneConnect';
import {loadStorage, removeStorage} from 'app/actions/storage';
import {goToSelectServer, setStoreFromLocalData} from 'app/actions/views/root';
@@ -33,4 +34,4 @@ function mapDispatchToProps(dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(Root);
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(Root);
diff --git a/app/scenes/select_server/select_server_container.js b/app/scenes/select_server/select_server_container.js
index f976a6a4b..cff3e2494 100644
--- a/app/scenes/select_server/select_server_container.js
+++ b/app/scenes/select_server/select_server_container.js
@@ -2,7 +2,8 @@
// See License.txt for license information.
import {bindActionCreators} from 'redux';
-import {connect} from 'react-redux';
+
+import navigationSceneConnect from '../navigationSceneConnect';
import {getPing} from 'service/actions/general';
import {goToLogin} from 'app/actions/navigation';
@@ -27,4 +28,4 @@ function mapDispatchToProps(dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(SelectServer);
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(SelectServer);
diff --git a/app/scenes/select_team/select_team_container.js b/app/scenes/select_team/select_team_container.js
index 17323e12e..06f10e629 100644
--- a/app/scenes/select_team/select_team_container.js
+++ b/app/scenes/select_team/select_team_container.js
@@ -2,7 +2,8 @@
// See License.txt for license information.
import {bindActionCreators} from 'redux';
-import {connect} from 'react-redux';
+
+import navigationSceneConnect from '../navigationSceneConnect';
import {goBack} from 'app/actions/navigation';
import {handleTeamChange} from 'app/actions/views/select_team';
@@ -30,4 +31,4 @@ function mapDispatchToProps(dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(SelectTeam);
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(SelectTeam);
diff --git a/package.json b/package.json
index 72d824dbc..fc796900a 100644
--- a/package.json
+++ b/package.json
@@ -29,13 +29,19 @@
"babel-preset-es2015": "6.18.0",
"babel-preset-react-native": "1.9.1",
"babel-register": "6.18.0",
+ "chai": "3.5.0",
+ "chai-enzyme": "0.6.1",
"deep-freeze": "0.0.1",
+ "enzyme": "2.7.1",
"eslint": "3.12.2",
"eslint-plugin-mocha": "4.8.0",
"eslint-plugin-react": "6.8.0",
"fetch-mock": "5.8.1",
"mocha": "3.2.0",
+ "react-addons-test-utils": "15.4.2",
+ "react-dom": "15.4.2",
"react-native-mock": "0.2.9",
+ "react-native-svg-mock": "1.0.2",
"react-test-renderer": "15.4.1",
"redux-logger": "2.7.4",
"remote-redux-devtools": "0.5.7",
@@ -47,7 +53,7 @@
"run-ios": "node node_modules/react-native/local-cli/cli.js run-ios",
"run-android": "node node_modules/react-native/local-cli/cli.js run-android",
"start": "node node_modules/react-native/local-cli/cli.js start -- --reset-cache",
- "test": "NODE_ENV=test mocha --compilers js:babel-register --require babel-polyfill",
+ "test": "NODE_ENV=test mocha --opts test/mocha.opts",
"postinstall": "make post-install"
}
}
diff --git a/test/mocha.opts b/test/mocha.opts
index 4be08b36b..1c51a4428 100644
--- a/test/mocha.opts
+++ b/test/mocha.opts
@@ -1,3 +1,7 @@
+--require test/setup.js
+--compilers js:babel-core/register
--require react-native-mock/mock.js
+--require react-native-svg-mock/mock
+--require babel-polyfill
--require isomorphic-fetch
--recursive
diff --git a/test/setup.js b/test/setup.js
new file mode 100644
index 000000000..0cda813a5
--- /dev/null
+++ b/test/setup.js
@@ -0,0 +1,52 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+// Setup recommendation from the following blog:
+// https://blog.addjam.com/testing-react-native-with-mocha-and-enzyme-6b77cd9e52a1#.2awpwqwwb
+
+/* eslint-disable */
+
+import fs from 'fs';
+import path from 'path';
+import register from 'babel-core/register';
+import chai from 'chai';
+import chaiEnzyme from 'chai-enzyme';
+
+const m = require('module');
+const originalLoader = m._load;
+
+// Image file ignore setup from:
+// http://valuemotive.com/2016/08/01/unit-testing-react-native-components-with-mocha-and-enzyme/
+m._load = function hookedLoader(request, parent, isMain) {
+ if (request.match(/.jpeg|.jpg|.png$/)) {
+ return {uri: request};
+ }
+
+ return originalLoader(request, parent, isMain);
+};
+
+// Ignore all node_modules except these
+const modulesToCompile = [
+ 'react-native',
+ 'react-native-mock',
+ 'react-native-svg-mock/mock',
+ 'react-native-vector-icons',
+ 'react-native-svg'
+].map((moduleName) => new RegExp(`/node_modules/${moduleName}`));
+
+const rcPath = path.join(__dirname, '..', '.babelrc');
+const source = fs.readFileSync(rcPath).toString();
+const config = JSON.parse(source);
+config.ignore = function(filename) {
+ if (!(/\/node_modules\//).test(filename)) {
+ return false;
+ }
+
+ const matches = modulesToCompile.filter((regex) => regex.test(filename));
+ const shouldIgnore = matches.length === 0;
+ return shouldIgnore;
+};
+
+register(config);
+
+chai.use(chaiEnzyme());