Commit c241759a authored by Sébastiaan Versteeg's avatar Sébastiaan Versteeg
Browse files

Merge branch 'feature/event-registration' into 'master'

Add event registration

Closes #20

See merge request thalia/ThaliApp-react!95
parents bb52fcfb becfde33
import { select } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';
import { throwError } from 'redux-saga-test-plan/providers';
import { apiRequest } from '../../app/url';
import { apiRequest, tokenSelector } from '../../app/url';
import eventSaga from '../../app/sagas/event';
import * as eventActions from '../../app/actions/event';
......@@ -14,6 +15,7 @@ describe('event api call', () => {
it('should start fetching', () => expectSaga(eventSaga)
.provide([
[select(tokenSelector), 'token'],
[matchers.call.fn(apiRequest), []],
])
.dispatch(eventActions.event(1))
......@@ -22,6 +24,7 @@ describe('event api call', () => {
it('should navigate to the event scene', () => expectSaga(eventSaga)
.provide([
[select(tokenSelector), 'token'],
[matchers.call.fn(apiRequest), []],
])
.dispatch(eventActions.event(1))
......@@ -30,18 +33,20 @@ describe('event api call', () => {
it('should put an error when the api request fails', () => expectSaga(eventSaga)
.provide([
[select(tokenSelector), 'token'],
[matchers.call.fn(apiRequest), throwError(error)],
])
.dispatch(eventActions.event(1, 'token'))
.dispatch(eventActions.event(1))
.put(eventActions.failure())
.silentRun());
it('should put the result data when the request succeeds', () => expectSaga(eventSaga)
.provide([
[select(tokenSelector), 'token'],
[matchers.call.like({ fn: apiRequest, args: ['events/1'] }), 'eventData'],
[matchers.call.like({ fn: apiRequest, args: ['events/1/registrations'] }), 'regData'],
])
.dispatch(eventActions.event(1, 'token'))
.dispatch(eventActions.event(1))
.put(eventActions.success('eventData', 'regData'))
.silentRun());
});
export const EVENT = 'EVENT_EVENT';
export const FETCHING = 'EVENT_FETCHING';
export const SUCCESS = 'EVENT_SUCCESS';
export const DONE = 'EVENT_DONE';
export const FAILURE = 'EVENT_FAILURE';
export function event(pk, token) {
export function event(pk) {
return {
type: EVENT,
payload: { pk, token },
payload: { pk },
};
}
......@@ -17,6 +18,12 @@ export function success(eventData, eventRegistrations) {
};
}
export function done() {
return {
type: DONE,
};
}
export function fetching() {
return {
type: FETCHING,
......
export const REGISTER = 'REGISTRATION_REGISTER';
export const UPDATE = 'REGISTRATION_UPDATE';
export const CANCEL = 'REGISTRATION_CANCEL';
export const FIELDS = 'REGISTRATION_FIELDS';
export const LOADING = 'REGISTRATION_FETCHING';
export const FAILURE = 'REGISTRATION_FAILURE';
export const SUCCESS = 'REGISTRATION_SUCCESS';
export const SHOW_FIELDS = 'REGISTRATION_SHOW_FIELDS';
export function register(event) {
return { type: REGISTER, payload: { event } };
}
export function update(registration, fields) {
return { type: UPDATE, payload: { registration, fields } };
}
export function cancel(registration) {
return { type: CANCEL, payload: { registration } };
}
export function retrieveFields(registration) {
return { type: FIELDS, payload: { registration } };
}
export function loading() {
return { type: LOADING };
}
export function failure() {
return { type: FAILURE };
}
export function success() {
return { type: SUCCESS };
}
export function showFields(registration, fields) {
return { type: SHOW_FIELDS, payload: { registration, fields } };
}
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Image, ScrollView, Text, View } from 'react-native';
import { Alert, Image, ScrollView, Text, View, RefreshControl, Button } from 'react-native';
import { connect } from 'react-redux';
import Moment from 'moment';
import 'moment/locale/nl';
......@@ -11,7 +11,19 @@ import LoadingScreen from './LoadingScreen';
import ErrorScreen from './ErrorScreen';
import { colors } from '../style';
import * as eventActions from '../actions/event';
import * as registrationActions from '../actions/registration';
class Event extends Component {
cancelPrompt = pk => Alert.alert(
'Cancel registration?',
'Are you sure you want to cancel your registration?',
[
{ text: 'Dismiss' },
{ text: 'Cancel registration', onPress: () => this.props.cancel(pk) },
],
);
eventDesc = (data) => {
const startDate = Moment(data.start).format('D MMM YYYY, HH:mm');
const endDate = Moment(data.end).format('D MMM YYYY, HH:mm');
......@@ -76,6 +88,8 @@ class Event extends Component {
let registrationState;
if (data.user_registration.is_late_cancellation) {
registrationState = 'Je bent afgemeld na de afmelddeadline';
} else if (data.user_registration.is_cancelled) {
registrationState = 'Je bent afgemeld';
} else if (data.user_registration.queue_position === null) {
registrationState = 'Je bent aangemeld';
} else if (data.user_registration.queue_position > 0) {
......@@ -140,42 +154,58 @@ class Event extends Component {
return (<View />);
};
// eslint-disable-next-line arrow-body-style
eventActions = () => {
eventActions = (event) => {
const nowDate = new Date();
const startRegDate = new Date(event.registration_start);
const endRegDate = new Date(event.registration_end);
const regRequired = event.registration_start !== null || event.registration_end !== null;
const regStarted = startRegDate <= nowDate;
const regAllowed = regRequired && endRegDate > nowDate &&
regStarted && event.registration_allowed;
// Needed once registration on server implemented
// if (event.registration_allowed) {
// if ((event.user_registration === null || event.user_registration.is_cancelled) &&
// (event.status === REGISTRATION_OPEN || event.status === REGISTRATION_OPEN_NO_CANCEL)) {
// const text = event.max_participants < event.num_participants ?
// 'Aanmelden' : 'Zet me op de wachtlijst';
// return (
// <View style={styles.registrationActions}>
// <Button color={colors.magenta} title={text} onPress={() => {}} />
// </View>
// );
// } else if (event.user_registration && !event.user_registration.is_cancelled &&
// event.status !== REGISTRATION_NOT_NEEDED && event.status !== REGISTRATION_NOT_YET_OPEN) {
// if ((event.status === REGISTRATION_OPEN || event.status === REGISTRATION_OPEN_NO_CANCEL)
// && event.user_registration && !event.user_registration.is_cancelled
// && event.has_fields) {
// return (
// <View style={styles.registrationActions}>
// <Button
// color={colors.magenta} title="Aanmelding bijwerken" onPress={() => {}}
// />
// <View style={styles.secondButtonMargin}>
// <Button color={colors.magenta} title="Afmelden" onPress={() => {}} />
// </View>
// </View>
// );
// }
// return (
// <View style={styles.registrationActions}>
// <Button color={colors.magenta} title="Afmelden" onPress={() => {}} />
// </View>
// );
// }
// }
if (regAllowed) {
if (event.user_registration === null || event.user_registration.is_cancelled) {
const text = event.max_participants && event.max_participants <= event.num_participants ?
'Zet me op de wachtlijst' : 'Aanmelden';
return (
<View style={styles.registrationActions}>
<Button
color={colors.magenta}
title={text}
onPress={() => this.props.register(event.pk)}
/>
</View>
);
} else if (event.user_registration && !event.user_registration.is_cancelled &&
regRequired && regStarted) {
if (regStarted && event.user_registration && !event.user_registration.is_cancelled &&
event.has_fields) {
return (
<View style={styles.registrationActions}>
<Button
color={colors.magenta}
title="Aanmelding bijwerken"
onPress={() => this.props.fields(event.user_registration.pk)}
/>
<View style={styles.secondButtonMargin}>
<Button
color={colors.magenta}
title="Afmelden"
onPress={() => this.cancelPrompt(event.user_registration.pk)}
/>
</View>
</View>
);
}
return (
<View style={styles.registrationActions}>
<Button color={colors.magenta} title="Afmelden" onPress={() => this.cancelPrompt(event.user_registration.pk)} />
</View>
);
}
}
return (<View />);
};
......@@ -230,14 +260,27 @@ class Event extends Component {
return (<View />);
};
handleRefresh = () => {
this.props.refresh(this.props.data.pk);
};
render() {
if (!this.props.hasLoaded) {
if (this.props.status === 'initial') {
return <LoadingScreen />;
}
if (this.props.success) {
if (this.props.status === 'success') {
return (
<ScrollView backgroundColor={colors.background} contentContainerStyle={styles.eventView}>
<ScrollView
backgroundColor={colors.background}
contentContainerStyle={styles.eventView}
refreshControl={(
<RefreshControl
refreshing={this.props.loading}
onRefresh={this.handleRefresh}
/>
)}
>
<Image
style={styles.locationImage}
source={{ uri: `https://maps.googleapis.com/maps/api/staticmap?center=${this.props.data.map_location}&zoom=13&size=450x250&markers=${this.props.data.map_location}` }}
......@@ -253,7 +296,18 @@ class Event extends Component {
);
}
return (
<ErrorScreen message="Could not load the event..." />
<ScrollView
backgroundColor={colors.background}
contentContainerStyle={styles.flex}
refreshControl={(
<RefreshControl
refreshing={this.props.loading}
onRefresh={this.handleRefresh}
/>
)}
>
<ErrorScreen message="Could not load the event..." />
</ScrollView>
);
}
}
......@@ -278,6 +332,7 @@ Event.propTypes = {
price: PropTypes.string,
fine: PropTypes.string,
user_registration: PropTypes.shape({
pk: PropTypes.number,
registered_on: PropTypes.string,
queue_position: PropTypes.number,
is_cancelled: PropTypes.bool,
......@@ -290,15 +345,26 @@ Event.propTypes = {
member: PropTypes.number,
name: PropTypes.string.isRequired,
})).isRequired,
success: PropTypes.bool.isRequired,
hasLoaded: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,
refresh: PropTypes.func.isRequired,
register: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
fields: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
data: state.event.data,
registrations: state.event.registrations,
success: state.event.success,
hasLoaded: state.event.hasLoaded,
status: state.event.status,
loading: state.event.loading,
});
const mapDispatchToProps = dispatch => ({
refresh: pk => dispatch(eventActions.event(pk)),
register: event => dispatch(registrationActions.register(event)),
cancel: registration => dispatch(registrationActions.cancel(registration)),
fields: registration => dispatch(registrationActions.retrieveFields(registration)),
});
export default connect(mapStateToProps, () => ({}))(Event);
export default connect(mapStateToProps, mapDispatchToProps)(Event);
......@@ -23,7 +23,7 @@ const getEventInfo = (event) => {
const EventCard = props => (
<TouchableHighlight
onPress={() => props.loadEvent(props.event, props.token)}
onPress={() => props.loadEvent(props.event)}
style={styles.button}
>
<View
......@@ -58,21 +58,16 @@ EventCard.propTypes = {
url: PropTypes.string,
}).isRequired,
loadEvent: PropTypes.func.isRequired,
token: PropTypes.string.isRequired,
};
const mapStateToProps = state => ({
token: state.session.token,
});
const mapDispatchToProps = dispatch => ({
loadEvent: (event, token) => {
loadEvent: (event) => {
if (event.partner) {
Linking.openURL(event.url);
} else {
dispatch(eventActions.event(event.pk, token));
dispatch(eventActions.event(event.pk));
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(EventCard);
export default connect(() => ({}), mapDispatchToProps)(EventCard);
......@@ -12,7 +12,6 @@ const LoadingScreen = () => (
<ActivityIndicator
animating
color={colors.magenta}
style={styles.indicator}
size="large"
/>
</View>
......
......@@ -236,7 +236,7 @@ class Profile extends Component {
return (
<View style={styles.container}>
<StatusBar
backgroundColor={colors.statusBar}
backgroundColor={colors.semiTransparent}
barStyle="light-content"
translucent
animated
......
import React, { Component } from 'react';
import { ActivityIndicator, Modal, View, ScrollView, Switch, TextInput, Text, Button } from 'react-native';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import styles from './style/registration';
import { colors } from '../style';
import ErrorScreen from './ErrorScreen';
import * as registrationActions from '../actions/registration';
class Registration extends Component {
constructor(props) {
super(props);
this.state = {};
const keys = Object.keys(props.fields);
for (let i = 0; i < keys.length; i += 1) {
const field = props.fields[keys[i]];
if (field.type === 'boolean') {
this.state[keys[i]] = Boolean(field.value);
} else if (field.type === 'integer' || field.type === 'text') {
this.state[keys[i]] = field.value === null ? '' : String(field.value);
} else {
this.state[keys[i]] = field.value;
}
}
}
getFieldValidity = (key) => {
const field = this.props.fields[key];
const value = this.state[key];
if (field.required) {
if (field.type === 'integer' && (value === '' || value === null || !value.match(/^-?\d+$/))) {
return {
isValid: false,
reason: 'This field is required and must be an integer.',
};
} else if (field.type === 'text' && (value === '' || value === null)) {
return {
isValid: false,
reason: 'This field is required.',
};
}
}
return {
isValid: true,
};
};
isFormValid = () => {
const keys = Object.keys(this.props.fields);
for (let i = 0; i < keys.length; i += 1) {
if (!this.getFieldValidity(keys[i]).isValid) {
return false;
}
}
return true;
};
updateField = (key, value) => {
const update = {};
update[key] = value;
this.setState(update);
};
render() {
if (this.props.status === 'failure') {
return <ErrorScreen message="Sorry! We couldn't load any data." />;
}
const keys = Object.keys(this.props.fields);
return (
<ScrollView
style={styles.content}
keyboardShouldPersistTaps="handled"
>
<Modal
visible={this.props.status === 'loading'}
transparent
onRequestClose={() => ({})}
>
<View style={styles.overlay}>
<ActivityIndicator
animating
color={colors.magenta}
size="large"
/>
</View>
</Modal>
{keys.map((key) => {
const field = this.props.fields[key];
const validity = this.getFieldValidity(key);
if (field.type === 'boolean') {
return (
<View key={key} style={styles.booleanContainer}>
<Text style={styles.field}>{field.label}</Text>
<Switch
value={this.state[key]}
onValueChange={value => this.updateField(key, value)}
thumbTintColor={this.state[key] ? colors.darkMagenta : colors.lightGray}
onTintColor={colors.magenta}
/>
</View>
);
} else if (field.type === 'integer' || field.type === 'text') {
return (
<View key={key} style={styles.fieldContainer}>
<Text style={styles.field}>{field.label}</Text>
<TextInput
value={this.state[key]}
onChangeText={value => this.updateField(key, value)}
keyboardType={field.type === 'integer' ? 'numeric' : 'default'}
style={styles.field}
underlineColorAndroid={validity.isValid ? colors.lightGray :
colors.lightRed}
placeholder={field.description}
/>
{validity.isValid || <Text style={styles.invalid}>{validity.reason}</Text>}
</View>
);
}
return <View />;
})}
<View style={styles.buttonView}>
<Button
title="Aanpassen"
color={colors.magenta}
onPress={() => this.props.update(this.props.registration, this.state)}
disabled={!this.isFormValid()}
/>
</View>
</ScrollView>
);
}
}
Registration.propTypes = {
registration: PropTypes.number.isRequired,
fields: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
status: PropTypes.string.isRequired,
update: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
registration: state.registration.registration,
fields: state.registration.fields,
status: state.registration.status,
});
const mapDispatchToProps = dispatch => ({
update: (registration, fields) => dispatch(registrationActions.update(registration, fields)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Registration);
......@@ -21,6 +21,8 @@ const sceneToTitle = (scene) => {
return 'Pizza';
case 'profile':
return 'Profiel';
case 'registration':
return 'Registratie';
default:
return 'ThaliApp';
}
......@@ -30,7 +32,7 @@ const StandardHeader = props => (
<View>
<View style={styles.statusBar}>
<StatusBar
backgroundColor={colors.statusBar}
backgroundColor={colors.semiTransparent}
translucent
animated
barStyle="light-content"
......
......@@ -11,6 +11,7 @@ import Calendar from './Calendar';
import Profile from './Profile';
import Pizza from './Pizza';
import StandardHeader from './StandardHeader';
import Registration from './Registration';
import * as actions from '../actions/navigation';
import styles from './style/navigator';
......@@ -28,6 +29,8 @@ const sceneToComponent = (scene) => {
return <Profile />;
case 'pizza':
return <Pizza />;
case 'registration':
return <Registration />;
default:
return <Welcome />;
}
......@@ -78,7 +81,7 @@ const ReduxNavigator = (props) => {
>
<View style={styles.statusBar}>
<StatusBar
backgroundColor={colors.statusBar}
backgroundColor={colors.semiTransparent}
barStyle="light-content"
translucent
animated
......
......@@ -118,6 +118,9 @@ const styles = create({
italicText: {
fontStyle: 'italic',
},
flex: {
flex: 1,
},
});
export default styles;