Verified Commit 194500ef authored by Sébastiaan Versteeg's avatar Sébastiaan Versteeg
Browse files

Add status to session with animated activity indicator on login screen

parent b1ade63b
......@@ -47,15 +47,6 @@ describe('session saga', () => {
const error = new Error('error');
describe('logging in', () => {
it('should show a snackbar on start', () => expectSaga(sessionSaga)
.dispatch(sessionActions.signIn('username', 'password'))
.silentRun()
.then(() => {
expect(Snackbar.show).toBeCalledWith(
{ title: 'Logging in', duration: Snackbar.LENGTH_INDEFINITE },
);
}));
it('should put the result data when the request succeeds', () => expectSaga(sessionSaga)
.provide([
[matchers.call.like({ fn: apiRequest, args: ['token-auth'] }), { token: 'abc123' }],
......@@ -74,7 +65,6 @@ describe('session saga', () => {
.dispatch(sessionActions.signIn('username', 'password'))
.silentRun()
.then(() => {
expect(Snackbar.dismiss).toBeCalled();
expect(Snackbar.show).toBeCalledWith(
{ title: 'Login successful' },
);
......@@ -94,6 +84,14 @@ describe('session saga', () => {
]);
}));
it('should put token invalid when the request fails', () => expectSaga(sessionSaga)
.provide([
[matchers.call.fn(apiRequest), throwError(error)],
])
.put(sessionActions.tokenInvalid())
.dispatch(sessionActions.signIn('username', 'password'))
.silentRun());
it('should show a snackbar when the request fails', () => expectSaga(sessionSaga)
.provide([
[matchers.call.fn(apiRequest), throwError(error)],
......@@ -101,7 +99,6 @@ describe('session saga', () => {
.dispatch(sessionActions.signIn('username', 'password'))
.silentRun()
.then(() => {
expect(Snackbar.dismiss).toBeCalled();
expect(Snackbar.show).toBeCalledWith(
{ title: 'Login failed' },
);
......
......@@ -24,7 +24,7 @@ describe('Sidebar component', () => {
it('renders correctly', () => {
const tree = renderer
.create(<Sidebar store={store} navigation={mockNavigation} />)
.create(<Sidebar store={store} navigation={mockNavigation} activeItemKey="unknown" />)
.toJSON();
expect(tree).toMatchSnapshot();
});
......
import React, { Component } from 'react';
import { Linking, Platform } from 'react-native';
import { Linking, Platform, NativeModules } from 'react-native';
import { applyMiddleware, createStore } from 'redux';
import { Provider } from 'react-redux';
import { I18nextProvider } from 'react-i18next';
......@@ -18,6 +18,13 @@ import * as deepLinkingActions from './actions/deepLinking';
import { register } from './actions/pushNotifications';
import NavigationService from './navigation';
const { UIManager } = NativeModules;
/* istanbul ignore next */
// eslint-disable-next-line no-unused-expressions
UIManager.setLayoutAnimationEnabledExperimental
&& UIManager.setLayoutAnimationEnabledExperimental(true);
const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducers, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(sagas);
......
......@@ -2,7 +2,12 @@ import { defaultProfileImage } from '../utils/url';
import * as sessionActions from '../actions/session';
export const STATUS_SIGNED_OUT = 'SIGNED_OUT';
export const STATUS_SIGNED_IN = 'SIGNED_IN';
export const STATUS_SIGNING_IN = 'SIGNING_IN';
const initialState = {
status: STATUS_SIGNED_OUT,
token: '',
username: '',
displayName: '',
......@@ -11,9 +16,15 @@ const initialState = {
export default function session(state = initialState, action = {}) {
switch (action.type) {
case sessionActions.SIGN_IN:
return {
...state,
status: STATUS_SIGNING_IN,
};
case sessionActions.SIGNED_IN:
return {
...state,
status: STATUS_SIGNED_IN,
username: action.payload.username,
token: action.payload.token,
};
......
import {
call, put, takeEvery, select,
} from 'redux-saga/effects';
import { delay } from 'redux-saga';
import { AsyncStorage } from 'react-native';
import Snackbar from 'react-native-snackbar';
import { Sentry } from 'react-native-sentry';
......@@ -52,8 +53,6 @@ function* init() {
function* signIn(action) {
const { user, pass } = action.payload;
Snackbar.show({ title: 'Logging in', duration: Snackbar.LENGTH_INDEFINITE });
const data = {
method: 'POST',
headers: {
......@@ -65,6 +64,9 @@ function* signIn(action) {
password: pass,
}),
};
const currentTimestamp = Date.now();
try {
const response = yield call(apiRequest, 'token-auth', data);
const { token } = response;
......@@ -76,11 +78,16 @@ function* signIn(action) {
yield put(sessionActions.signedIn(user, token));
yield put(sessionActions.fetchUserInfo());
yield put(pushNotificationsActions.register());
Snackbar.dismiss();
Snackbar.show({ title: 'Login successful' });
} catch (e) {
// Delay failure to make sure animation is finished
const now = Date.now();
if (now - currentTimestamp < 150) {
yield call(delay, now - currentTimestamp);
}
yield put(sessionActions.tokenInvalid());
Sentry.captureException(e);
Snackbar.dismiss();
Snackbar.show({ title: 'Login failed' });
}
}
......
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
ActivityIndicator,
Image,
Keyboard,
KeyboardAvoidingView,
......@@ -8,6 +9,7 @@ import {
Text,
TextInput,
View,
LayoutAnimation,
} from 'react-native';
import { connect } from 'react-redux';
import { translate } from 'react-i18next';
......@@ -18,9 +20,17 @@ import DismissKeyboardView from '../../components/dismissKeyboardView/DismissKey
import Button from '../../components/button/Button';
import styles from './style/Login';
import Colors from '../../style/Colors';
import { STATUS_SIGNING_IN } from '../../../reducers/session';
const image = require('../../../assets/img/logo.png');
const configureNextAnimation = () => {
/* istanbul ignore next */
LayoutAnimation.configureNext(LayoutAnimation.create(150,
LayoutAnimation.Types.linear, LayoutAnimation.Properties.opacity));
};
class Login extends Component {
constructor(props) {
super(props);
......@@ -30,8 +40,61 @@ class Login extends Component {
};
}
componentDidMount() {
configureNextAnimation();
}
render() {
const { login, t } = this.props;
configureNextAnimation();
const { login, t, status } = this.props;
let content = (
<View>
<View>
<TextInput
style={styles.input}
placeholder={t('Username')}
autoCapitalize="none"
underlineColorAndroid={Colors.textColour}
onChangeText={username => this.setState({ username })}
/>
<TextInput
style={styles.input}
placeholder={t('Password')}
underlineColorAndroid={Colors.textColour}
autoCapitalize="none"
secureTextEntry
onChangeText={password => this.setState({ password })}
onSubmitEditing={() => {
login(this.state.username, this.state.password);
}}
/>
</View>
<Button
title={t('LOGIN')}
onPress={() => login(this.state.username, this.state.password)}
color={Colors.darkGrey}
style={styles.loginButton}
textStyle={styles.loginButtonText}
underlayColor={Colors.white}
/>
<Text style={styles.forgotpass} onPress={() => Linking.openURL(`${url}/password_reset/`)}>
{t('Forgot password?')}
</Text>
</View>
);
if (status === STATUS_SIGNING_IN) {
content = (
<ActivityIndicator
style={styles.activityIndicator}
color={Colors.white}
size="large"
animating
/>
);
}
return (
<KeyboardAvoidingView
style={styles.rootWrapper}
......@@ -42,37 +105,7 @@ class Login extends Component {
contentStyle={styles.wrapper}
>
<Image style={styles.logo} source={image} />
<View>
<TextInput
style={styles.input}
placeholder={t('Username')}
autoCapitalize="none"
underlineColorAndroid={Colors.textColour}
onChangeText={username => this.setState({ username })}
/>
<TextInput
style={styles.input}
placeholder={t('Password')}
underlineColorAndroid={Colors.textColour}
autoCapitalize="none"
secureTextEntry
onChangeText={password => this.setState({ password })}
onSubmitEditing={() => {
login(this.state.username, this.state.password);
}}
/>
</View>
<Button
title={t('LOGIN')}
onPress={() => login(this.state.username, this.state.password)}
color={Colors.darkGrey}
style={styles.loginButton}
textStyle={styles.loginButtonText}
underlayColor={Colors.white}
/>
<Text style={styles.forgotpass} onPress={() => Linking.openURL(`${url}/password_reset/`)}>
{t('Forgot password?')}
</Text>
{content}
</DismissKeyboardView>
</KeyboardAvoidingView>
);
......@@ -82,6 +115,7 @@ class Login extends Component {
Login.propTypes = {
login: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
status: PropTypes.string.isRequired,
};
const mapStateToProps = state => state.session;
......
......@@ -61,6 +61,9 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
},
activityIndicator: {
marginTop: 24,
},
});
export default styles;
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment