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());