diff --git a/lib/actions/user.js b/lib/actions/user.js index aa8e8d095..83d54036c 100644 --- a/lib/actions/user.js +++ b/lib/actions/user.js @@ -5,14 +5,16 @@ import { secureFetch } from '../util/middleware' import { isNewUser } from '../util/user' // Middleware API paths. -const API_MONITORTRIP_PATH = '/api/secure/monitoredtrip' +const API_MONITORED_TRIP_PATH = '/api/secure/monitoredtrip' const API_OTPUSER_PATH = '/api/secure/user' -const API_OTPUSER_VERIFYSMS_PATH = '/verify_sms' +const API_OTPUSER_VERIFY_SMS_SUBPATH = '/verify_sms' const setCurrentUser = createAction('SET_CURRENT_USER') const setCurrentUserMonitoredTrips = createAction('SET_CURRENT_USER_MONITORED_TRIPS') const setLastPhoneSmsRequest = createAction('SET_LAST_PHONE_SMS_REQUEST') export const setPathBeforeSignIn = createAction('SET_PATH_BEFORE_SIGNIN') +export const clearItineraryExistence = createAction('CLEAR_ITINERARY_EXISTENCE') +const setitineraryExistence = createAction('SET_ITINERARY_EXISTENCE') function createNewUser (auth0User) { return { @@ -160,7 +162,7 @@ export function createOrUpdateUser (userData, silentOnSuccess = false) { export function fetchUserMonitoredTrips () { return async function (dispatch, getState) { const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) - const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}` + const requestUrl = `${apiBaseUrl}${API_MONITORED_TRIP_PATH}` const { data: trips, status } = await secureFetch(requestUrl, accessToken, apiKey, 'GET') if (status === 'success') { @@ -182,10 +184,10 @@ export function createOrUpdateUserMonitoredTrip (tripData, isNew, silentOnSucces // Determine URL and method to use. if (isNew) { - requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}` + requestUrl = `${apiBaseUrl}${API_MONITORED_TRIP_PATH}` method = 'POST' } else { - requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}/${id}` + requestUrl = `${apiBaseUrl}${API_MONITORED_TRIP_PATH}/${id}` method = 'PUT' } @@ -217,7 +219,7 @@ export function createOrUpdateUserMonitoredTrip (tripData, isNew, silentOnSucces export function deleteUserMonitoredTrip (tripId) { return async function (dispatch, getState) { const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) - const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}/${tripId}` + const requestUrl = `${apiBaseUrl}${API_MONITORED_TRIP_PATH}/${tripId}` const { message, status } = await secureFetch(requestUrl, accessToken, apiKey, 'DELETE') if (status === 'success') { @@ -243,7 +245,7 @@ export function requestPhoneVerificationSms (newPhoneNumber) { // TODO: Should throttling be handled in the middleware? if (number !== newPhoneNumber || (now - timestamp) >= 60000) { const { accessToken, apiBaseUrl, apiKey, loggedInUser } = getMiddlewareVariables(state) - const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/${loggedInUser.id}${API_OTPUSER_VERIFYSMS_PATH}/${encodeURIComponent(newPhoneNumber)}` + const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/${loggedInUser.id}${API_OTPUSER_VERIFY_SMS_SUBPATH}/${encodeURIComponent(newPhoneNumber)}` const { message, status } = await secureFetch(requestUrl, accessToken, apiKey, 'GET') @@ -273,7 +275,7 @@ export function requestPhoneVerificationSms (newPhoneNumber) { export function verifyPhoneNumber (code) { return async function (dispatch, getState) { const { accessToken, apiBaseUrl, apiKey, loggedInUser } = getMiddlewareVariables(getState()) - const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/${loggedInUser.id}${API_OTPUSER_VERIFYSMS_PATH}/${code}` + const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/${loggedInUser.id}${API_OTPUSER_VERIFY_SMS_SUBPATH}/${code}` const { data, status } = await secureFetch(requestUrl, accessToken, apiKey, 'POST') @@ -293,3 +295,26 @@ export function verifyPhoneNumber (code) { } } } + +/** + * Check itinerary existence for the given monitored trip. + */ +export function checkItineraryExistence (trip) { + return async function (dispatch, getState) { + const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) + const requestUrl = `${apiBaseUrl}${API_MONITORED_TRIP_PATH}/checkitinerary` + + // Empty state before performing the checks. + dispatch(clearItineraryExistence()) + + const { data, status } = await secureFetch(requestUrl, accessToken, apiKey, 'POST', { + body: JSON.stringify(trip) + }) + + if (status === 'success' && data) { + dispatch(setitineraryExistence(data)) + } else { + alert('Error checking whether your selected trip is possible.') + } + } +} diff --git a/lib/components/narrative/narrative-itineraries.js b/lib/components/narrative/narrative-itineraries.js index 93b245fa6..56c7f9ee0 100644 --- a/lib/components/narrative/narrative-itineraries.js +++ b/lib/components/narrative/narrative-itineraries.js @@ -38,7 +38,7 @@ class NarrativeItineraries extends Component { static propTypes = { itineraries: PropTypes.array, itineraryClass: PropTypes.func, - pending: PropTypes.number, + pending: PropTypes.bool, activeItinerary: PropTypes.number, setActiveItinerary: PropTypes.func, setActiveLeg: PropTypes.func, diff --git a/lib/components/user/trip-basics-pane.js b/lib/components/user/trip-basics-pane.js index 5b1bbaa25..928f7b697 100644 --- a/lib/components/user/trip-basics-pane.js +++ b/lib/components/user/trip-basics-pane.js @@ -1,27 +1,32 @@ import { Field } from 'formik' -import React from 'react' +import React, { Component } from 'react' import { ControlLabel, FormControl, FormGroup, - HelpBlock + Glyphicon, + HelpBlock, + ProgressBar } from 'react-bootstrap' +import { connect } from 'react-redux' import styled from 'styled-components' -import { ALL_DAYS } from '../../util/monitored-trip' +import * as userActions from '../../actions/user' import TripSummary from './trip-summary' // Styles. -const StyledLabel = styled.label` +const TripDayLabel = styled.label` border: 1px solid #ccc; border-left: none; box-sizing: border-box; display: inline-block; font-weight: inherit; + float: left; + height: 3em; max-width: 150px; min-width: 14.28%; text-align: center; - & > span { + & > span:first-of-type { display: block; width: 100%; } @@ -31,73 +36,133 @@ const StyledLabel = styled.label` ` const allDays = [ - { name: 'monday', text: 'Mon.' }, - { name: 'tuesday', text: 'Tue.' }, - { name: 'wednesday', text: 'Wed.' }, - { name: 'thursday', text: 'Thu.' }, - { name: 'friday', text: 'Fri.' }, - { name: 'saturday', text: 'Sat.' }, - { name: 'sunday', text: 'Sun.' } + { name: 'monday', text: 'Mon.', fullText: 'Mondays' }, + { name: 'tuesday', text: 'Tue.', fullText: 'Tuesdays' }, + { name: 'wednesday', text: 'Wed.', fullText: 'Wednesdays' }, + { name: 'thursday', text: 'Thu.', fullText: 'Thursdays' }, + { name: 'friday', text: 'Fri.', fullText: 'Fridays' }, + { name: 'saturday', text: 'Sat.', fullText: 'Saturdays' }, + { name: 'sunday', text: 'Sun.', fullText: 'Sundays' } ] /** * This component shows summary information for a trip * and lets the user edit the trip name and day. */ -const TripBasicsPane = ({ errors, touched, values: monitoredTrip }) => { - const { itinerary } = monitoredTrip - - if (!itinerary) { - return
No itinerary to display.
- } else { - // Show an error indicaton when monitoredTrip.tripName is not blank (from the form's validation schema) - // and that tripName is not already used. - let tripNameValidationState = null - if (touched.tripName) { - tripNameValidationState = errors.tripName ? 'error' : null +class TripBasicsPane extends Component { + componentDidMount () { + // Check itinerary availability (existence) for all days. + const { checkItineraryExistence, values: monitoredTrip } = this.props + checkItineraryExistence(monitoredTrip) + } + + componentDidUpdate (prevProps) { + const { isCreating, itineraryExistence, setFieldValue } = this.props + + if (itineraryExistence !== prevProps.itineraryExistence) { + // For new trips only, + // update the Formik state to uncheck days for which the itinerary is not available. + if (isCreating && itineraryExistence) { + allDays.forEach(({ name }) => { + if (!itineraryExistence[name].valid) { + setFieldValue(name, false) + } + }) + } } + } - // Show a combined error indicaton when no day is selected. - let monitoredDaysValidationState = null - ALL_DAYS.forEach(day => { - if (touched[day]) { - if (!monitoredDaysValidationState) { - monitoredDaysValidationState = errors[day] ? 'error' : null - } + componentWillUnmount () { + this.props.clearItineraryExistence() + } + + render () { + const { errors, isCreating, itineraryExistence, touched, values: monitoredTrip } = this.props + const { itinerary } = monitoredTrip + + if (!itinerary) { + return
No itinerary to display.
+ } else { + // Show an error indicaton when monitoredTrip.tripName is not blank (from the form's validation schema) + // and that tripName is not already used. + let tripNameValidationState = null + if (touched.tripName) { + tripNameValidationState = errors.tripName ? 'error' : null } - }) - - return ( -
- Selected itinerary: - - - - Please provide a name for this trip: - {/* onBlur, onChange, and value are passed automatically. */} - - - {errors.tripName && {errors.tripName}} - - - - What days to you take this trip? -
- {allDays.map(({ name, text }, index) => ( - - {text} - - - ))} -
- {monitoredDaysValidationState && Please select at least one day to monitor.} -
-
- ) + + // Show a combined error indicaton when no day is selected. + let monitoredDaysValidationState = null + allDays.forEach(({ name }) => { + if (touched[name]) { + if (!monitoredDaysValidationState) { + monitoredDaysValidationState = errors[name] ? 'error' : null + } + } + }) + + return ( +
+ Selected itinerary: + + + + Please provide a name for this trip: + {/* onBlur, onChange, and value are passed automatically. */} + + + {errors.tripName && {errors.tripName}} + + + + What days do you take this trip? +
+ {allDays.map(({ name, fullText, text }, index) => { + const isDayDisabled = itineraryExistence && !itineraryExistence[name].valid + const boxClass = isDayDisabled ? 'alert-danger' : (monitoredTrip[name] ? 'bg-primary' : '') + const notAvailableText = isDayDisabled ? `Trip not available on ${fullText}` : null + + return ( + + {text} + { // Let users save an existing trip, even though it may not be available on some days. + // TODO: improve checking trip availability. + isDayDisabled && isCreating + ? + : + } + + ) + })} +
+
+ + {itineraryExistence + ? ( + <>Your trip is available on the days of the week as indicated above. + ) : ( + + ) + } + + {monitoredDaysValidationState && Please select at least one day to monitor.} + +
+ ) + } } } -export default TripBasicsPane +// Connect to redux store +const mapStateToProps = (state, ownProps) => { + const { itineraryExistence } = state.user + return { + itineraryExistence + } +} + +const mapDispatchToProps = { + checkItineraryExistence: userActions.checkItineraryExistence, + clearItineraryExistence: userActions.clearItineraryExistence +} + +export default connect(mapStateToProps, mapDispatchToProps)(TripBasicsPane) diff --git a/lib/reducers/create-user-reducer.js b/lib/reducers/create-user-reducer.js index eb5a6b430..8e7123658 100644 --- a/lib/reducers/create-user-reducer.js +++ b/lib/reducers/create-user-reducer.js @@ -4,6 +4,7 @@ import update from 'immutability-helper' function createUserReducer () { const initialState = { accessToken: null, + itineraryExistence: null, lastPhoneSmsRequest: { number: null, status: null, @@ -41,6 +42,18 @@ function createUserReducer () { }) } + case 'SET_ITINERARY_EXISTENCE': { + return update(state, { + itineraryExistence: { $set: action.payload } + }) + } + + case 'CLEAR_ITINERARY_EXISTENCE': { + return update(state, { + itineraryExistence: { $set: null } + }) + } + default: return state }